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 { /// /// Picks a Case + optional subfolder (filesystem-style, via Marcus-Law's /// NetworkStorageIntegration) for saving raw attachment files. /// 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; /// /// Resolved server-side case folder path for the currently /// selected case (e.g. "47-אריאל מונאס"). Populated by /// after the /// case + primary contact have been fetched. Empty until then. /// public string? ResolvedCaseFolderPath { get; private set; } public ObservableCollection Cases { get; } = new ObservableCollection(); public ObservableCollection Subfolders { get; } = new ObservableCollection(); public ObservableCollection Attachments { get; } = new ObservableCollection(); public bool? DialogResult { get; private set; } public IReadOnlyList SelectedAttachments { get { var list = new List(); foreach (var row in Attachments) { if (row.IsSelected) list.Add(row.Data); } return list; } } /// /// Subfolder string the user picked, or null to save to the case /// folder root. /// 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 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(); } /// /// 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 /// using the same logic as the server (primary contact's name, not /// the case's lawsuit title). /// 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(); 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); } } /// /// 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) "<number>-<primary contact name>" ← server's recipe /// 3) "<number>-<case lawsuit name>" /// 4) contact / case name on its own /// 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); } /// /// Mirrors LocalFilesystemClient::sanitizeFileName on the server side /// so client-computed paths match what the integration writes. /// 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 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(); 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>(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); } } }