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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ namespace OutlookAddin
|
||||
try
|
||||
{
|
||||
AddInHost = new AddInHost(this.Application);
|
||||
AddInHost.InitializeSidebar(this.CustomTaskPanes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user