This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OutlookAddin/src/OutlookAddin.UI/ViewModels/ReadingPaneViewModel.cs
T
PointStar 2368764251 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>
2026-05-11 15:42:40 +03:00

224 lines
7.8 KiB
C#

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