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)
|
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||||
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
=> 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)
|
public async Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
|
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<AccountEntity> GetAccountAsync(string id, string? select = null, CancellationToken cancellationToken = default);
|
||||||
Task<ContactEntity> GetContactAsync(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<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);
|
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="PresentationCore" />
|
||||||
<Reference Include="PresentationFramework" />
|
<Reference Include="PresentationFramework" />
|
||||||
<Reference Include="WindowsBase" />
|
<Reference Include="WindowsBase" />
|
||||||
|
<Reference Include="WindowsFormsIntegration" />
|
||||||
<Reference Include="System.Xaml" />
|
<Reference Include="System.Xaml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -234,12 +235,18 @@
|
|||||||
<Compile Include="Services\AddInHost.cs">
|
<Compile Include="Services\AddInHost.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<Compile Include="Services\ExplorerSelectionHandler.cs">
|
||||||
|
<SubType>Code</SubType>
|
||||||
|
</Compile>
|
||||||
<Compile Include="Services\MailItemExtractor.cs">
|
<Compile Include="Services\MailItemExtractor.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
<Compile Include="Services\RetryQueueProcessor.cs">
|
<Compile Include="Services\RetryQueueProcessor.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<Compile Include="Services\SidebarController.cs">
|
||||||
|
<SubType>Code</SubType>
|
||||||
|
</Compile>
|
||||||
<AppDesigner Include="Properties\" />
|
<AppDesigner Include="Properties\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ using MarcusLaw.OutlookAddin.Core.Security;
|
|||||||
using MarcusLaw.OutlookAddin.Core.Services;
|
using MarcusLaw.OutlookAddin.Core.Services;
|
||||||
using MarcusLaw.OutlookAddin.UI.Dialogs;
|
using MarcusLaw.OutlookAddin.UI.Dialogs;
|
||||||
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Office.Tools;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||||
|
|
||||||
@@ -34,10 +36,13 @@ namespace OutlookAddin.Services
|
|||||||
|
|
||||||
public IEspoCrmClient? CrmClient { get; private set; }
|
public IEspoCrmClient? CrmClient { get; private set; }
|
||||||
public IFilingService? FilingService { get; private set; }
|
public IFilingService? FilingService { get; private set; }
|
||||||
|
public IMatchingService? MatchingService { get; private set; }
|
||||||
|
|
||||||
private readonly Outlook.Application _outlookApp;
|
private readonly Outlook.Application _outlookApp;
|
||||||
|
private readonly MemoryCache _matchCache = new MemoryCache(new MemoryCacheOptions());
|
||||||
private RetryQueueProcessor? _retryProcessor;
|
private RetryQueueProcessor? _retryProcessor;
|
||||||
private HttpClient? _httpClient;
|
private HttpClient? _httpClient;
|
||||||
|
private SidebarController? _sidebarController;
|
||||||
|
|
||||||
public AddInHost(Outlook.Application outlookApp)
|
public AddInHost(Outlook.Application outlookApp)
|
||||||
{
|
{
|
||||||
@@ -53,6 +58,56 @@ namespace OutlookAddin.Services
|
|||||||
TryInitializeCrm();
|
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 bool IsConfigured => CrmClient != null && FilingService != null;
|
||||||
|
|
||||||
public event EventHandler? CrmClientChanged;
|
public event EventHandler? CrmClientChanged;
|
||||||
@@ -81,6 +136,7 @@ namespace OutlookAddin.Services
|
|||||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
CrmClient = new EspoCrmClient(_httpClient, baseUrl.ToString(), creds.Username, creds.ApiKey, Logger);
|
CrmClient = new EspoCrmClient(_httpClient, baseUrl.ToString(), creds.Username, creds.ApiKey, Logger);
|
||||||
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
||||||
|
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
||||||
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
|
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
|
||||||
_retryProcessor.Start();
|
_retryProcessor.Start();
|
||||||
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
|
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
|
||||||
@@ -102,6 +158,7 @@ namespace OutlookAddin.Services
|
|||||||
_httpClient = null;
|
_httpClient = null;
|
||||||
CrmClient = null;
|
CrmClient = null;
|
||||||
FilingService = null;
|
FilingService = null;
|
||||||
|
MatchingService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -362,8 +419,10 @@ namespace OutlookAddin.Services
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
try { _sidebarController?.Dispose(); } catch { }
|
||||||
try { _retryProcessor?.Dispose(); } catch { }
|
try { _retryProcessor?.Dispose(); } catch { }
|
||||||
try { _httpClient?.Dispose(); } catch { }
|
try { _httpClient?.Dispose(); } catch { }
|
||||||
|
try { _matchCache.Dispose(); } catch { }
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Logger.Information("AddInHost shutting down");
|
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
|
try
|
||||||
{
|
{
|
||||||
AddInHost = new AddInHost(this.Application);
|
AddInHost = new AddInHost(this.Application);
|
||||||
|
AddInHost.InitializeSidebar(this.CustomTaskPanes);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user