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/SaveAttachmentsViewModel.cs
T
PointStar b6624a924f feat(save-attachments): show case#, court#, status in candidate picker
Replace the single-line ListBox with a DataGrid (case number, name,
court case number, status) so users disambiguate same-named cases
(e.g. two "שלומוב זויה" cases). Hydrates each search hit via
GET /Case/{id}?select=id,name,number,status,cCourtCaseNumber in
parallel so columns populate without an extra click. CourtCaseNumber
is mapped to the EspoCRM custom field cCourtCaseNumber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:51:15 +03:00

343 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
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>
/// Picks a Case + optional subfolder (filesystem-style, via Marcus-Law's
/// NetworkStorageIntegration) for saving raw attachment files.
/// </summary>
public sealed partial class SaveAttachmentsViewModel : ObservableObject
{
// Default subfolders the integration creates under each Case folder.
// Mirrors Resources/metadata/app/networkStorage.json on the server.
private static readonly string[] DefaultCaseSubfolders =
{
"מסמכים",
"אסמכתאות",
"התכתבויות",
"כללי"
};
private const string RootSentinel = "(שורש התיק)";
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
private CancellationTokenSource? _searchCts;
[ObservableProperty]
private string searchText = string.Empty;
[ObservableProperty]
private CaseEntity? selectedCase;
[ObservableProperty]
private string? selectedSubfolder;
[ObservableProperty]
private string? statusMessage;
/// <summary>
/// Resolved server-side case folder path for the currently
/// selected case (e.g. "47-אריאל מונאס"). Populated by
/// <see cref="RefreshSubfoldersForSelectedCaseAsync"/> after the
/// case + primary contact have been fetched. Empty until then.
/// </summary>
public string? ResolvedCaseFolderPath { get; private set; }
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
public bool? DialogResult { get; private set; }
public IReadOnlyList<EspoAttachment> SelectedAttachments
{
get
{
var list = new List<EspoAttachment>();
foreach (var row in Attachments)
{
if (row.IsSelected) list.Add(row.Data);
}
return list;
}
}
/// <summary>
/// Subfolder string the user picked, or null to save to the case
/// folder root.
/// </summary>
public string? ChosenSubfolderName =>
string.IsNullOrEmpty(SelectedSubfolder) ||
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
? null
: SelectedSubfolder;
public CaseEntity? PickedCase => SelectedCase;
public event EventHandler? RequestClose;
public SaveAttachmentsViewModel(
IEspoCrmClient client,
ILogger logger,
IEnumerable<EspoAttachment> attachments)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ResetSubfolders();
foreach (var att in attachments)
{
Attachments.Add(new AttachmentRowViewModel(att));
}
}
private void ResetSubfolders()
{
Subfolders.Clear();
Subfolders.Add(RootSentinel);
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
SelectedSubfolder = Subfolders[0];
}
partial void OnSearchTextChanged(string value)
{
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
_ = SearchAsync(value, _searchCts.Token);
}
partial void OnSelectedCaseChanged(CaseEntity? value)
{
SubmitCommand.NotifyCanExecuteChanged();
_ = RefreshSubfoldersForSelectedCaseAsync();
}
/// <summary>
/// Refreshes the subfolder dropdown to merge static defaults with
/// the actual folders that already exist on the network share for
/// the chosen case. Also resolves <see cref="ResolvedCaseFolderPath"/>
/// using the same logic as the server (primary contact's name, not
/// the case's lawsuit title).
/// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync()
{
ResetSubfolders();
ResolvedCaseFolderPath = null;
var c = SelectedCase;
if (c == null || string.IsNullOrEmpty(c.Id)) return;
try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
string? primaryContactName = null;
try
{
var detail = await _client.GetCaseAsync(
c.Id, "id,name,number,contactsIds,networkStorageFolderPath", default).ConfigureAwait(true);
c.Number = detail.Number;
c.Name = detail.Name;
c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath;
c.ContactsIds = detail.ContactsIds ?? new List<string>();
if (c.ContactsIds.Count > 0)
{
try
{
var contact = await _client.GetContactAsync(c.ContactsIds[0], "id,name", default).ConfigureAwait(true);
primaryContactName = contact?.Name;
}
catch (Exception ex) { _logger.Warning(ex, "Primary contact fetch failed for case {CaseId}", c.Id); }
}
}
catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); }
var folderPath = ResolveCaseFolderPath(c, primaryContactName);
ResolvedCaseFolderPath = folderPath;
_logger.Information(
"Resolved case folder for {CaseId} (number={Number}, contact={Contact}, stored={Stored}) → {Path}",
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
if (string.IsNullOrEmpty(folderPath)) return;
try
{
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
foreach (var item in items)
{
if (!item.IsFolder) continue;
if (Subfolders.Contains(item.Name)) continue;
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
Subfolders.Add(item.Name);
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
}
}
/// <summary>
/// Mirrors NetworkDocumentService::buildEntityFolderName for Case
/// on the server side. Fallback order:
/// 1) Case.networkStorageFolderPath (if the integration's hook
/// has set it — most authoritative)
/// 2) "&lt;number&gt;-&lt;primary contact name&gt;" ← server's recipe
/// 3) "&lt;number&gt;-&lt;case lawsuit name&gt;"
/// 4) contact / case name on its own
/// </summary>
public static string ResolveCaseFolderPath(CaseEntity c, string? primaryContactName = null)
{
if (c == null) return string.Empty;
if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath))
{
return c.NetworkStorageFolderPath!.TrimEnd('/');
}
var nameForFolder = !string.IsNullOrWhiteSpace(primaryContactName)
? primaryContactName!
: (c.Name ?? string.Empty);
var raw = !string.IsNullOrWhiteSpace(c.Number)
? $"{c.Number}-{nameForFolder}"
: nameForFolder;
return SanitizeFolderSegment(raw);
}
/// <summary>
/// Mirrors LocalFilesystemClient::sanitizeFileName on the server side
/// so client-computed paths match what the integration writes.
/// </summary>
public static string SanitizeFolderSegment(string name)
{
if (string.IsNullOrWhiteSpace(name)) return string.Empty;
foreach (var c in new[] { '׳', '״', '\'', '"', '', '', '“', '”' })
{
name = name.Replace(c.ToString(), string.Empty);
}
foreach (var c in new[] { '/', '\\', ':', '*', '?', '<', '>', '|' })
{
name = name.Replace(c, '-');
}
while (name.Contains("--")) name = name.Replace("--", "-");
while (name.Contains(" ")) name = name.Replace(" ", " ");
name = name.Replace("-.", ".");
return name.Trim(' ', '-');
}
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
{
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
if (string.IsNullOrEmpty(hit.Id)) return row;
try
{
var detail = await _client.GetCaseAsync(
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
row.Number = detail.Number;
row.Status = detail.Status;
row.CourtCaseNumber = detail.CourtCaseNumber;
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.Warning(ex, "Hydrating case {CaseId} for picker failed", hit.Id);
}
return row;
}
private async Task SearchAsync(string query, CancellationToken ct)
{
try
{
await Task.Delay(250, ct).ConfigureAwait(true);
var trimmed = (query ?? string.Empty).Trim();
if (trimmed.Length < 2)
{
Cases.Clear();
StatusMessage = null;
return;
}
var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
Cases.Clear();
var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List)
{
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
caseHits.Add(hit);
}
if (caseHits.Count == 0)
{
StatusMessage = "אין תוצאות מסוג תיק (Case)";
return;
}
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
foreach (var hit in caseHits)
{
detailTasks.Add(LoadCaseRowAsync(hit, ct));
}
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
foreach (var row in details)
{
if (row != null) Cases.Add(row);
}
StatusMessage = $"נמצאו {Cases.Count} תיקים";
}
catch (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException)
{
StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
}
catch (Exception ex)
{
_logger.Warning(ex, "SaveAttachments search failed");
StatusMessage = "שגיאת חיפוש: " + ex.Message;
}
}
[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit()
{
DialogResult = true;
RequestClose?.Invoke(this, EventArgs.Empty);
}
private bool CanSubmit()
{
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return false;
foreach (var row in Attachments)
{
if (row.IsSelected) return true;
}
return false;
}
[RelayCommand]
private void Cancel()
{
DialogResult = false;
RequestClose?.Invoke(this, EventArgs.Empty);
}
}
}