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 18b6640e57 feat(save-attachments): editable filenames, Hebrew status, smoother subfolder load
- DataGrid case picker now shows status via CaseStatusToHebrewConverter
  (added PendingHearing / PendingDecision mappings)
- Attachment row replaces the read-only filename TextBlock with a TwoWay
  TextBox so users can rename a file before saving (flows through
  EspoAttachment.Name into the upload + the on-disk path)
- SaveAttachmentsViewModel.IsLoadingSubfolders gates the folder ComboBox
  while EnsureCaseSubfolders / GetCase / ListNetworkStorageFolder run,
  so users can't pick a stale "(שורש התיק)" before the real list arrives
- About tab: ResolveDisplayVersion now reads from the host VSTO assembly
  (*.OutlookAddin) instead of the UI assembly. The UI csproj is SDK-style
  with no <Version> so it always reported 1.0.0.0, which leaked into the
  About line on dev installs
- Bump to 1.1.2.0 (csproj + AssemblyInfo) to match the v1.1.2 tag that
  will trigger the Gitea Actions auto-publish to platform.dev

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

359 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;
[ObservableProperty]
private bool isLoadingSubfolders;
/// <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;
IsLoadingSubfolders = true;
try
{
await RefreshSubfoldersCoreAsync(c).ConfigureAwait(true);
}
finally
{
IsLoadingSubfolders = false;
}
}
private async Task RefreshSubfoldersCoreAsync(CaseEntity c)
{
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);
}
}
}