feat(sidebar): Task #4 — reading-pane sidebar with sender matching

Core:
- IEspoCrmClient.ListCasesForContactAsync (paged where[]=linkedWith query)
- EspoListResponse<T> generic envelope for /list endpoints
- IMatchingService + MatchingService: EmailAddress/search → Contact →
  Account → recent 5 Cases. MemoryCache 10-min TTL; caches null and
  ambiguous results too. Returns null for 0 matches, IsAmbiguous=true
  for 2+, populated MatchResult for exactly 1.
- MatchResult DTO with Contact / Account / RecentCases / IsAmbiguous

UI:
- ReadingPaneView (WPF UserControl, RTL): 6 states — Empty / Loading /
  Match / NoMatch / Ambiguous / Error — switched via Visibility bindings
- ReadingPaneViewModel: state machine, FileRequested + OpenInBrowserRequested
  events, Reset()/LoadAsync() with cancellation chain (every selection
  cancels the previous lookup)

Host:
- ExplorerSelectionHandler: subscribes to Outlook.Explorer.SelectionChange,
  200ms debounce via Timer, dedupes by last sender, marshals to WPF
  dispatcher before reading COM state
- SidebarController: attaches a CustomTaskPane per Explorer
  (ElementHost wrapping ReadingPaneView), tracks bindings by COM identity,
  unhooks on Explorer.Close. Sidebar defers attach until CRM client is
  configured (subscribes to CrmClientChanged)
- AddInHost: MatchingService field, MemoryCache shared across lookups,
  InitializeSidebar(taskPanes) invoked from ThisAddIn_Startup

Core/UI/Tests green (38 tests passing); VSTO host still needs VS
Installer Repair locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-11 15:42:40 +03:00
parent aa65546fb1
commit 2368764251
14 changed files with 958 additions and 0 deletions
@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Models
{
public sealed class EspoListResponse<T>
{
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("list")]
public List<T> List { get; set; } = new List<T>();
}
}
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace MarcusLaw.OutlookAddin.Core.Models
{
public sealed class MatchResult
{
public string SenderEmail { get; set; } = string.Empty;
public ContactEntity? Contact { get; set; }
public AccountEntity? Account { get; set; }
public List<CaseEntity> RecentCases { get; set; } = new List<CaseEntity>();
public bool IsAmbiguous { get; set; }
public int CandidateCount { get; set; }
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
}
}
@@ -155,6 +155,20 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
public Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
if (maxSize <= 0) maxSize = 5;
var path =
"Case?select=" + Uri.EscapeDataString("id,name,number,status,createdAt,accountId") +
"&where[0][type]=linkedWith" +
"&where[0][attribute]=contacts" +
"&where[0][value]=" + Uri.EscapeDataString(contactId) +
"&orderBy=createdAt&order=desc" +
"&maxSize=" + maxSize;
return GetJsonAsync<EspoListResponse<CaseEntity>>(path, cancellationToken);
}
public async Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
@@ -13,6 +13,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Task<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default);
Task<ContactEntity> GetContactAsync(string id, string? select = null, CancellationToken cancellationToken = default);
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
namespace MarcusLaw.OutlookAddin.Core.Services
{
public interface IMatchingService
{
/// <summary>
/// Looks up the contact/account/recent-cases bundle for a sender
/// email address. Returns null when there is no unique Contact match
/// (0 or 2+ candidates). Results are cached for 10 minutes.
/// </summary>
Task<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default);
void Invalidate(string senderEmail);
}
}
@@ -0,0 +1,122 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
using Microsoft.Extensions.Caching.Memory;
using Serilog;
namespace MarcusLaw.OutlookAddin.Core.Services
{
public sealed class MatchingService : IMatchingService
{
private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(10);
private const string ContactSelect = "id,name,emailAddress,accountId,accountName";
private readonly IEspoCrmClient _client;
private readonly IMemoryCache _cache;
private readonly ILogger _logger;
public MatchingService(IEspoCrmClient client, IMemoryCache cache, ILogger logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(senderEmail)) return null;
if (!senderEmail.Contains("@")) return null;
var key = CacheKey(senderEmail);
if (_cache.TryGetValue<MatchResult?>(key, out var cached))
{
return cached;
}
try
{
var search = await _client.EmailAddressSearchAsync(senderEmail, "Contact", cancellationToken).ConfigureAwait(false);
if (search.List.Count == 0)
{
_cache.Set(key, (MatchResult?)null, CacheExpiry);
return null;
}
if (search.List.Count > 1)
{
var ambiguous = new MatchResult
{
SenderEmail = senderEmail,
IsAmbiguous = true,
CandidateCount = search.List.Count
};
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous;
}
var contactRef = search.List[0];
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
AccountEntity? account = null;
if (!string.IsNullOrWhiteSpace(contact.AccountId))
{
try
{
account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for {Email}", contact.AccountId, senderEmail);
}
}
var cases = new System.Collections.Generic.List<CaseEntity>();
try
{
var list = await _client.ListCasesForContactAsync(contact.Id, 5, cancellationToken).ConfigureAwait(false);
cases.AddRange(list.List);
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
}
var result = new MatchResult
{
SenderEmail = senderEmail,
Contact = contact,
Account = account,
RecentCases = cases,
CandidateCount = 1
};
_cache.Set<MatchResult?>(key, result, CacheExpiry);
return result;
}
catch (EspoCrmAuthorizationException)
{
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService lookup failed for {Email}", senderEmail);
return null;
}
}
public void Invalidate(string senderEmail)
{
if (string.IsNullOrWhiteSpace(senderEmail)) return;
_cache.Remove(CacheKey(senderEmail));
}
private static string CacheKey(string senderEmail)
=> "match:" + senderEmail.Trim().ToLowerInvariant();
}
}
@@ -0,0 +1,223 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
/// <summary>
/// Possible states of the sidebar. Each maps to a Visibility binding
/// in <c>ReadingPaneView.xaml</c>.
/// </summary>
public enum ReadingPaneState
{
Empty,
Loading,
Match,
NoMatch,
Ambiguous,
Error
}
public sealed partial class ReadingPaneViewModel : ObservableObject
{
private readonly IMatchingService _matchingService;
private readonly ILogger _logger;
private CancellationTokenSource? _activeLookup;
[ObservableProperty]
private ReadingPaneState state = ReadingPaneState.Empty;
[ObservableProperty]
private string senderEmail = string.Empty;
[ObservableProperty]
private MatchResult? match;
[ObservableProperty]
private string? errorMessage;
[ObservableProperty]
private string? ambiguousMessage;
[ObservableProperty]
private string? espoCrmBaseUrl;
public ObservableCollection<CaseEntity> RecentCases { get; } = new ObservableCollection<CaseEntity>();
public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty);
public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading);
public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match);
public Visibility NoMatchVisibility => Vis(State == ReadingPaneState.NoMatch);
public Visibility AmbiguousVisibility => Vis(State == ReadingPaneState.Ambiguous);
public Visibility ErrorVisibility => Vis(State == ReadingPaneState.Error);
public Visibility AccountVisibility => Vis(Match?.Account != null);
public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0);
public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match);
/// <summary>
/// Raised when the user clicks "File to this case" (carries the chosen
/// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null).
/// Host wires this to <c>AddInHost.LaunchFileToCaseAsync</c>.
/// </summary>
public event EventHandler<FileFromSidebarEventArgs>? FileRequested;
/// <summary>
/// Raised when the user clicks "Open in EspoCRM". The host opens the URL.
/// </summary>
public event EventHandler<string>? OpenInBrowserRequested;
public ReadingPaneViewModel(IMatchingService matchingService, ILogger logger)
{
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
partial void OnStateChanged(ReadingPaneState value)
{
OnPropertyChanged(nameof(EmptyVisibility));
OnPropertyChanged(nameof(LoadingVisibility));
OnPropertyChanged(nameof(MatchVisibility));
OnPropertyChanged(nameof(NoMatchVisibility));
OnPropertyChanged(nameof(AmbiguousVisibility));
OnPropertyChanged(nameof(ErrorVisibility));
OnPropertyChanged(nameof(ActionsVisibility));
OnPropertyChanged(nameof(NoCasesVisibility));
FileToThisCommand.NotifyCanExecuteChanged();
FileElsewhereCommand.NotifyCanExecuteChanged();
OpenInEspoCrmCommand.NotifyCanExecuteChanged();
}
partial void OnMatchChanged(MatchResult? value)
{
RecentCases.Clear();
if (value?.RecentCases != null)
{
foreach (var c in value.RecentCases) RecentCases.Add(c);
}
OnPropertyChanged(nameof(AccountVisibility));
OnPropertyChanged(nameof(NoCasesVisibility));
}
public void Reset()
{
CancelInFlight();
SenderEmail = string.Empty;
Match = null;
ErrorMessage = null;
AmbiguousMessage = null;
State = ReadingPaneState.Empty;
}
public async Task LoadAsync(string senderEmail)
{
CancelInFlight();
if (string.IsNullOrWhiteSpace(senderEmail))
{
Reset();
return;
}
SenderEmail = senderEmail;
State = ReadingPaneState.Loading;
var cts = new CancellationTokenSource();
_activeLookup = cts;
try
{
var result = await _matchingService.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true);
if (cts.IsCancellationRequested) return;
if (result == null)
{
Match = null;
State = ReadingPaneState.NoMatch;
return;
}
if (result.IsAmbiguous)
{
Match = result;
AmbiguousMessage = $"נמצאו {result.CandidateCount} אנשי קשר עם הכתובת הזו — נדרשת בחירה ידנית.";
State = ReadingPaneState.Ambiguous;
return;
}
Match = result;
State = ReadingPaneState.Match;
}
catch (OperationCanceledException)
{
// Selection changed before lookup finished — ignore.
}
catch (EspoCrmAuthorizationException)
{
ErrorMessage = "מפתח ה-API נדחה. עדכן בהגדרות.";
State = ReadingPaneState.Error;
}
catch (Exception ex)
{
_logger.Warning(ex, "ReadingPaneViewModel.LoadAsync failed for {Email}", senderEmail);
ErrorMessage = "שגיאת התחברות לשרת.";
State = ReadingPaneState.Error;
}
}
private void CancelInFlight()
{
try { _activeLookup?.Cancel(); } catch { /* ignore */ }
_activeLookup = null;
}
[RelayCommand(CanExecute = nameof(CanFileToThis))]
private void FileToThis()
{
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!));
}
private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null;
[RelayCommand(CanExecute = nameof(CanFileElsewhere))]
private void FileElsewhere()
{
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
}
private bool CanFileElsewhere() => true;
[RelayCommand(CanExecute = nameof(CanOpenInCrm))]
private void OpenInEspoCrm()
{
if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || Match?.Contact == null) return;
var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Contact/view/" + Match.Contact.Id;
OpenInBrowserRequested?.Invoke(this, url);
}
private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null;
[RelayCommand]
private void OpenManualSearch()
{
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
}
private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed;
}
public sealed class FileFromSidebarEventArgs : EventArgs
{
public MatchResult? PreselectedMatch { get; }
public FileFromSidebarEventArgs(MatchResult? preselected)
{
PreselectedMatch = preselected;
}
}
}
@@ -0,0 +1,121 @@
<UserControl x:Class="MarcusLaw.OutlookAddin.UI.Views.ReadingPaneView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
FlowDirection="RightToLeft"
Background="White"
FontFamily="Segoe UI"
FontSize="12">
<UserControl.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style x:Key="LabelStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="Gray" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Margin" Value="0,4,0,2" />
</Style>
<Style x:Key="LinkButtonStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#0066CC" />
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
</UserControl.Resources>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="פרטי תיק" FontWeight="Bold" FontSize="14" Margin="0,0,0,8" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<Grid>
<!-- Loading -->
<StackPanel Visibility="{Binding LoadingVisibility}" HorizontalAlignment="Center" VerticalAlignment="Center">
<ProgressBar IsIndeterminate="True" Width="120" Height="6" Margin="0,0,0,8" />
<TextBlock Text="טוען…" Foreground="Gray" HorizontalAlignment="Center" />
</StackPanel>
<!-- Empty / no selection -->
<TextBlock Visibility="{Binding EmptyVisibility}"
Text="סמן מייל כדי לראות את פרטי התיק"
Foreground="Gray" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" />
<!-- No match -->
<StackPanel Visibility="{Binding NoMatchVisibility}">
<TextBlock Text="לא נמצא איש קשר ל-" Foreground="Gray" />
<TextBlock Text="{Binding SenderEmail}" FontWeight="Bold" />
<Button Content="חפש ידנית ב-EspoCRM"
Command="{Binding OpenManualSearchCommand}"
Style="{StaticResource LinkButtonStyle}"
Margin="0,12,0,0" />
</StackPanel>
<!-- Ambiguous -->
<StackPanel Visibility="{Binding AmbiguousVisibility}">
<TextBlock Text="{Binding AmbiguousMessage}" Foreground="DarkOrange" />
<Button Content="פתח חיפוש ידני"
Command="{Binding OpenManualSearchCommand}"
Style="{StaticResource LinkButtonStyle}"
Margin="0,12,0,0" />
</StackPanel>
<!-- Match -->
<StackPanel Visibility="{Binding MatchVisibility}">
<TextBlock Style="{StaticResource LabelStyle}" Text="איש קשר" />
<TextBlock Text="{Binding Match.Contact.Name}" FontSize="14" FontWeight="Bold" />
<TextBlock Text="{Binding Match.Contact.EmailAddress}" Foreground="Gray" />
<TextBlock Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" />
<TextBlock Text="{Binding Match.Account.Name}" Visibility="{Binding AccountVisibility}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="תיקים אחרונים" Margin="0,12,0,2" />
<ItemsControl ItemsSource="{Binding RecentCases}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
<StackPanel>
<TextBlock>
<Run Text="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{Binding Status}" Foreground="Gray" FontSize="10" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="(אין תיקים פתוחים)" Foreground="Gray" FontStyle="Italic"
Visibility="{Binding NoCasesVisibility}" Margin="0,4,0,0" />
</StackPanel>
<!-- Error -->
<TextBlock Visibility="{Binding ErrorVisibility}"
Text="{Binding ErrorMessage}"
Foreground="Firebrick" TextWrapping="Wrap" />
</Grid>
</ScrollViewer>
<StackPanel Grid.Row="2" Margin="0,8,0,0" Visibility="{Binding ActionsVisibility}">
<Button Content="תייק לתיק הזה"
Command="{Binding FileToThisCommand}"
Padding="8,4" Margin="0,0,0,4"
HorizontalAlignment="Stretch" />
<Button Content="תייק למקום אחר…"
Command="{Binding FileElsewhereCommand}"
Padding="8,4" Margin="0,0,0,4"
HorizontalAlignment="Stretch" />
<Button Content="פתח ב-EspoCRM"
Command="{Binding OpenInEspoCrmCommand}"
Padding="8,4"
HorizontalAlignment="Stretch" />
</StackPanel>
</Grid>
</UserControl>
@@ -0,0 +1,12 @@
using System.Windows.Controls;
namespace MarcusLaw.OutlookAddin.UI.Views
{
public partial class ReadingPaneView : UserControl
{
public ReadingPaneView()
{
InitializeComponent();
}
}
}
+7
View File
@@ -116,6 +116,7 @@
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="WindowsBase" />
<Reference Include="WindowsFormsIntegration" />
<Reference Include="System.Xaml" />
</ItemGroup>
<ItemGroup>
@@ -234,12 +235,18 @@
<Compile Include="Services\AddInHost.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\ExplorerSelectionHandler.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\MailItemExtractor.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\RetryQueueProcessor.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\SidebarController.cs">
<SubType>Code</SubType>
</Compile>
<AppDesigner Include="Properties\" />
</ItemGroup>
<ItemGroup>
+59
View File
@@ -12,6 +12,8 @@ using MarcusLaw.OutlookAddin.Core.Security;
using MarcusLaw.OutlookAddin.Core.Services;
using MarcusLaw.OutlookAddin.UI.Dialogs;
using MarcusLaw.OutlookAddin.UI.ViewModels;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Office.Tools;
using Serilog;
using Outlook = Microsoft.Office.Interop.Outlook;
@@ -34,10 +36,13 @@ namespace OutlookAddin.Services
public IEspoCrmClient? CrmClient { get; private set; }
public IFilingService? FilingService { get; private set; }
public IMatchingService? MatchingService { get; private set; }
private readonly Outlook.Application _outlookApp;
private readonly MemoryCache _matchCache = new MemoryCache(new MemoryCacheOptions());
private RetryQueueProcessor? _retryProcessor;
private HttpClient? _httpClient;
private SidebarController? _sidebarController;
public AddInHost(Outlook.Application outlookApp)
{
@@ -53,6 +58,56 @@ namespace OutlookAddin.Services
TryInitializeCrm();
}
/// <summary>
/// Called by ThisAddIn_Startup once the VSTO task-pane collection is
/// available. Spawns the right-hand "Case Info" pane on every Explorer.
/// </summary>
public void InitializeSidebar(CustomTaskPaneCollection taskPanes)
{
if (taskPanes == null) throw new ArgumentNullException(nameof(taskPanes));
if (_sidebarController != null) return;
if (MatchingService == null)
{
Logger.Information("AddInHost: sidebar deferred — MatchingService not yet available");
CrmClientChanged += OnCrmClientChangedForSidebar;
_pendingTaskPanes = taskPanes;
return;
}
StartSidebar(taskPanes);
}
private CustomTaskPaneCollection? _pendingTaskPanes;
private void OnCrmClientChangedForSidebar(object? sender, EventArgs e)
{
if (MatchingService == null || _pendingTaskPanes == null || _sidebarController != null) return;
StartSidebar(_pendingTaskPanes);
CrmClientChanged -= OnCrmClientChangedForSidebar;
_pendingTaskPanes = null;
}
private void StartSidebar(CustomTaskPaneCollection taskPanes)
{
try
{
_sidebarController = new SidebarController(
taskPanes,
_outlookApp,
MatchingService!,
this,
System.Windows.Threading.Dispatcher.CurrentDispatcher,
Logger);
_sidebarController.Initialize();
Logger.Information("AddInHost: sidebar attached");
}
catch (Exception ex)
{
Logger.Error(ex, "AddInHost: sidebar attach failed");
}
}
public bool IsConfigured => CrmClient != null && FilingService != null;
public event EventHandler? CrmClientChanged;
@@ -81,6 +136,7 @@ namespace OutlookAddin.Services
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
CrmClient = new EspoCrmClient(_httpClient, baseUrl.ToString(), creds.Username, creds.ApiKey, Logger);
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
_retryProcessor.Start();
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
@@ -102,6 +158,7 @@ namespace OutlookAddin.Services
_httpClient = null;
CrmClient = null;
FilingService = null;
MatchingService = null;
}
/// <summary>
@@ -362,8 +419,10 @@ namespace OutlookAddin.Services
public void Dispose()
{
try { _sidebarController?.Dispose(); } catch { }
try { _retryProcessor?.Dispose(); } catch { }
try { _httpClient?.Dispose(); } catch { }
try { _matchCache.Dispose(); } catch { }
try
{
Logger.Information("AddInHost shutting down");
@@ -0,0 +1,143 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Windows.Threading;
using MarcusLaw.OutlookAddin.UI.ViewModels;
using Serilog;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace OutlookAddin.Services
{
/// <summary>
/// Subscribes to <see cref="Outlook.Explorer.SelectionChange"/>, debounces
/// the storm of events Outlook fires while the user is scrolling
/// (typically every ~30ms), and forwards the resulting sender-email
/// lookup to the <see cref="ReadingPaneViewModel"/>.
/// </summary>
public sealed class ExplorerSelectionHandler : IDisposable
{
public TimeSpan DebounceInterval { get; }
private readonly Outlook.Explorer _explorer;
private readonly ReadingPaneViewModel _viewModel;
private readonly Dispatcher _dispatcher;
private readonly ILogger _logger;
private readonly Timer _timer;
private string? _lastSenderEmail;
private int _disposed;
public ExplorerSelectionHandler(
Outlook.Explorer explorer,
ReadingPaneViewModel viewModel,
Dispatcher dispatcher,
ILogger logger,
TimeSpan? debounce = null)
{
_explorer = explorer ?? throw new ArgumentNullException(nameof(explorer));
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
DebounceInterval = debounce ?? TimeSpan.FromMilliseconds(200);
_timer = new Timer(OnTimerFired, null, Timeout.Infinite, Timeout.Infinite);
_explorer.SelectionChange += OnSelectionChange;
}
public void TriggerNow()
{
OnSelectionChange();
}
private void OnSelectionChange()
{
if (Volatile.Read(ref _disposed) != 0) return;
try
{
_timer.Change(DebounceInterval, Timeout.InfiniteTimeSpan);
}
catch (ObjectDisposedException) { }
}
private void OnTimerFired(object? state)
{
if (Volatile.Read(ref _disposed) != 0) return;
try
{
_dispatcher.BeginInvoke((Action)ProcessOnUiThread);
}
catch (Exception ex)
{
_logger.Warning(ex, "ExplorerSelectionHandler: dispatcher invoke failed");
}
}
private void ProcessOnUiThread()
{
string? senderEmail = ReadSelectedSenderEmail();
// Only kick a lookup when the sender has actually changed; otherwise
// we'd hammer the cache for every keyboard arrow press on the same row.
if (string.Equals(senderEmail, _lastSenderEmail, StringComparison.OrdinalIgnoreCase)) return;
_lastSenderEmail = senderEmail;
if (string.IsNullOrWhiteSpace(senderEmail))
{
_viewModel.Reset();
return;
}
_ = _viewModel.LoadAsync(senderEmail);
}
private string? ReadSelectedSenderEmail()
{
Outlook.Selection? selection = null;
object? entry = null;
try
{
selection = _explorer.Selection;
if (selection == null || selection.Count == 0) return null;
entry = selection[1];
if (entry is Outlook.MailItem mail)
{
var smtp = SafeGet(() => mail.SenderEmailAddress);
if (!string.IsNullOrWhiteSpace(smtp) && smtp.Contains("@"))
{
return smtp;
}
}
return null;
}
catch (Exception ex)
{
_logger.Warning(ex, "ExplorerSelectionHandler: ReadSelectedSenderEmail failed");
return null;
}
finally
{
Release(entry);
Release(selection);
}
}
private static T? SafeGet<T>(Func<T> getter) where T : class
{
try { return getter(); } catch { return null; }
}
private static void Release(object? com)
{
if (com != null && Marshal.IsComObject(com))
{
try { Marshal.ReleaseComObject(com); } catch { /* ignore */ }
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
try { _explorer.SelectionChange -= OnSelectionChange; } catch { /* ignore */ }
try { _timer.Dispose(); } catch { /* ignore */ }
}
}
}
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Forms.Integration;
using System.Windows.Threading;
using MarcusLaw.OutlookAddin.Core.Services;
using MarcusLaw.OutlookAddin.UI.ViewModels;
using MarcusLaw.OutlookAddin.UI.Views;
using Microsoft.Office.Tools;
using Serilog;
using Office = Microsoft.Office.Core;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace OutlookAddin.Services
{
/// <summary>
/// Owns the per-Explorer "Case Info" task pane. For each active Explorer
/// window we host a single <see cref="ReadingPaneView"/> wrapped in
/// <see cref="ElementHost"/> and a <see cref="ExplorerSelectionHandler"/>
/// that drives lookups.
/// </summary>
public sealed class SidebarController : IDisposable
{
private const string PaneTitle = "פרטי תיק (EspoCRM)";
private const int DefaultWidth = 320;
private readonly CustomTaskPaneCollection _taskPanes;
private readonly Outlook.Application _app;
private readonly IMatchingService _matchingService;
private readonly AddInHost _host;
private readonly Dispatcher _dispatcher;
private readonly ILogger _logger;
private readonly Dictionary<int, ExplorerBinding> _bindings = new Dictionary<int, ExplorerBinding>();
private Outlook.Explorers? _explorers;
public SidebarController(
CustomTaskPaneCollection taskPanes,
Outlook.Application app,
IMatchingService matchingService,
AddInHost host,
Dispatcher dispatcher,
ILogger logger)
{
_taskPanes = taskPanes ?? throw new ArgumentNullException(nameof(taskPanes));
_app = app ?? throw new ArgumentNullException(nameof(app));
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_host = host ?? throw new ArgumentNullException(nameof(host));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Initialize()
{
try
{
_explorers = _app.Explorers;
_explorers.NewExplorer += OnNewExplorer;
// Attach to whatever Explorer is open right now.
var active = _app.ActiveExplorer();
if (active != null)
{
AttachToExplorer(active);
}
}
catch (Exception ex)
{
_logger.Error(ex, "SidebarController.Initialize failed");
}
}
private void OnNewExplorer(Outlook.Explorer explorer)
{
try { AttachToExplorer(explorer); }
catch (Exception ex) { _logger.Warning(ex, "SidebarController.OnNewExplorer failed"); }
}
private void AttachToExplorer(Outlook.Explorer explorer)
{
var key = ExplorerKey(explorer);
if (_bindings.ContainsKey(key)) return;
var view = new ReadingPaneView();
var vm = new ReadingPaneViewModel(_matchingService, _logger)
{
EspoCrmBaseUrl = _host.Settings.EspoCrmUrl
};
view.DataContext = vm;
var element = new ElementHost
{
Child = view,
Dock = System.Windows.Forms.DockStyle.Fill
};
var pane = _taskPanes.Add(element, PaneTitle, explorer);
pane.DockPosition = Office.MsoCTPDockPosition.msoCTPDockPositionRight;
pane.Width = DefaultWidth;
pane.Visible = true;
var handler = new ExplorerSelectionHandler(explorer, vm, _dispatcher, _logger);
vm.FileRequested += async (s, e) =>
{
try { await _host.LaunchFileToCaseAsync().ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "Sidebar file-request handler failed"); }
};
vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url);
explorer.Close += () => DetachExplorer(key);
_bindings[key] = new ExplorerBinding(pane, element, vm, handler, explorer);
// Kick a first lookup based on whatever is selected when the pane opens.
handler.TriggerNow();
}
private void DetachExplorer(int key)
{
if (!_bindings.TryGetValue(key, out var binding)) return;
_bindings.Remove(key);
binding.Dispose();
}
private static int ExplorerKey(Outlook.Explorer explorer)
{
// RuntimeCallableWrapper identity is unstable across RCW reads,
// but the underlying COM pointer is stable; use it as the dictionary key.
var ptr = Marshal.GetIUnknownForObject(explorer);
try { return ptr.GetHashCode(); }
finally { Marshal.Release(ptr); }
}
private void TryOpenInBrowser(string url)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
catch (Exception ex)
{
_logger.Warning(ex, "TryOpenInBrowser failed for {Url}", url);
}
}
public void Dispose()
{
try
{
if (_explorers != null)
{
_explorers.NewExplorer -= OnNewExplorer;
}
}
catch { /* ignore */ }
foreach (var binding in _bindings.Values)
{
binding.Dispose();
}
_bindings.Clear();
}
private sealed class ExplorerBinding : IDisposable
{
private readonly CustomTaskPane _pane;
private readonly ElementHost _element;
private readonly ExplorerSelectionHandler _handler;
private readonly Outlook.Explorer _explorer;
public ReadingPaneViewModel ViewModel { get; }
public ExplorerBinding(
CustomTaskPane pane,
ElementHost element,
ReadingPaneViewModel viewModel,
ExplorerSelectionHandler handler,
Outlook.Explorer explorer)
{
_pane = pane;
_element = element;
ViewModel = viewModel;
_handler = handler;
_explorer = explorer;
}
public void Dispose()
{
try { _handler.Dispose(); } catch { /* ignore */ }
try { _pane.Visible = false; } catch { /* ignore */ }
try { _element.Dispose(); } catch { /* ignore */ }
}
}
}
}
+1
View File
@@ -14,6 +14,7 @@ namespace OutlookAddin
try
{
AddInHost = new AddInHost(this.Application);
AddInHost.InitializeSidebar(this.CustomTaskPanes);
}
catch (Exception ex)
{