feat(attachments): rewire to Marcus-Law NetworkStorageIntegration

After reading the NetworkStorageIntegration repo I realized the previous
"save attachments" flow was wrong on every level — Marcus-Law doesn't
use EspoCRM's stock Document/DocumentFolder model. Folders are real
filesystem paths under the network share, named after the Case (format
"<number>-<primary contact name>") with four standard subfolders:
מסמכים, אסמכתאות, התכתבויות, כללי.

Rewire the save pipeline against the integration's real endpoints
(documented in routes.json on the server repo):

Core — three new IEspoCrmClient methods:
- EnsureCaseSubfoldersAsync(caseId)
    POST /NetworkStorage/action/createSubfolders
    Idempotent — creates case folder + defaults if missing.
- ListNetworkStorageFolderAsync(path)
    GET /NetworkStorage/folderContents?path=…
    Returns folders + files; we filter to type=folder for the dropdown.
- UploadAttachmentToNetworkStorageAsync(attachmentId, targetPath)
    POST /NetworkStorage/action/upload
    Moves the staged Attachment binary onto the share at <path>/<name>.

CaseEntity gets a new `networkStorageFolderPath` field (set by the
integration's hook on first Document save; empty until then).

NetworkStorageItem DTO captures the folder-listing rows.

AttachmentSaveService rewritten — no more Document creation, no more
DocumentFolder picker. The new contract is:
    SaveAsync(attachments, targetFolderPath)
Where targetFolderPath = "<case folder>[/subfolder]". For each
attachment: upload binary via standard /Attachment, then move via
NetworkStorage upload.

UI — SaveAttachmentsViewModel:
- Subfolder dropdown switched from DocumentFolder objects to strings.
- Static defaults loaded up-front:
    "(שורש התיק)" + 4 server-side defaults.
- When the user picks a case:
    1) hydrate Case (number + networkStorageFolderPath) via GetCase
    2) call EnsureCaseSubfoldersAsync to materialise standard folders
    3) listFolder at the case path to merge any custom subfolders into
       the dropdown.
- ResolveCaseFolderPath: use Case.networkStorageFolderPath if set,
  otherwise compute "<number>-<contact-name>" with a client-side
  SanitizeFolderSegment that mirrors LocalFilesystemClient::sanitizeFileName
  on the server (geresh/gershayim/quotes removed, illegal chars → hyphen).

AddInHost.LaunchSaveAttachmentsAsync:
- Composes <caseFolder>[/subfolder] from the VM choice.
- Pre-flight ensureCaseSubfolders as a safety net (in case the user
  submitted before the dialog's lazy refresh completed).
- Summary MessageBox now shows the resolved storage path instead of a
  bare entity name.

Tests: 39 passing. The build is clean across Core / UI / Tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-12 21:42:22 +03:00
parent 037d573e6e
commit 5242abb585
9 changed files with 325 additions and 68 deletions
@@ -31,5 +31,12 @@ namespace MarcusLaw.OutlookAddin.Core.Models
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; set; }
// Set by Marcus-Law's NetworkStorageIntegration hook on first
// Document save. Empty until a document has been written through
// EspoCRM. When empty we compute it client-side from number +
// primary contact name.
[JsonPropertyName("networkStorageFolderPath")]
public string? NetworkStorageFolderPath { get; set; }
}
}
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Models
{
/// <summary>
/// One row from GET /NetworkStorage/folderContents — folder or file.
/// </summary>
public sealed class NetworkStorageItem
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty; // "folder" or "file"
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("modified")]
public string? Modified { get; set; }
public bool IsFolder => string.Equals(Type, "folder", System.StringComparison.OrdinalIgnoreCase);
}
}
@@ -20,16 +20,17 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public async Task<AttachmentSaveSummary> SaveAsync(
IReadOnlyList<EspoAttachment> attachments,
FilingTarget target,
string? folderId,
string targetFolderPath,
CancellationToken cancellationToken = default)
{
if (attachments == null) throw new ArgumentNullException(nameof(attachments));
if (target == null) throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(targetFolderPath)) throw new ArgumentException("targetFolderPath required", nameof(targetFolderPath));
var summary = new AttachmentSaveSummary();
if (attachments.Count == 0) return summary;
var trimmedFolder = targetFolderPath.TrimEnd('/');
foreach (var att in attachments)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -47,21 +48,26 @@ namespace MarcusLaw.OutlookAddin.Core.Services
try
{
// Step 1: create the EspoCRM Attachment (binary stored
// in data/upload/<id>; not yet on the network share).
var uploaded = await _client.UploadAttachmentAsync(
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
var doc = await _client.CreateDocumentAsync(
att.Name, uploaded.Id, target.ParentType, target.ParentId, folderId, cancellationToken).ConfigureAwait(false);
// Step 2: ask Marcus-Law's NetworkStorageIntegration
// to place the uploaded blob under <targetFolderPath>.
// The integration appends the filename to the path.
await _client.UploadAttachmentToNetworkStorageAsync(
uploaded.Id, trimmedFolder, cancellationToken).ConfigureAwait(false);
_logger.Information(
"Saved attachment {Name} → Document/{DocId} (parent={ParentType}/{ParentId}, folder={FolderId})",
att.Name, doc.Id, target.ParentType, target.ParentId, folderId ?? "(none)");
"Saved attachment {Name} → {Path} (attachmentId={AttachmentId})",
att.Name, trimmedFolder, uploaded.Id);
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.Saved,
DocumentId = doc.Id
AttachmentId = uploaded.Id
});
}
catch (EspoCrmAuthorizationException ex)
@@ -386,6 +386,117 @@ namespace MarcusLaw.OutlookAddin.Core.Services
}
}
public async Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var payload = new Dictionary<string, object?>
{
["entityType"] = "Case",
["entityId"] = caseId
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/createSubfolders", json, cancellationToken).ConfigureAwait(false);
try
{
if (!response.IsSuccessStatusCode)
{
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
_logger.Warning(
"EnsureCaseSubfolders({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
caseId, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
}
// Don't throw — folder creation is best-effort. Upload will fail
// with its own clear error if the parent really isn't there.
return;
}
_logger.Information("EnsureCaseSubfolders({CaseId}) OK", caseId);
}
finally
{
response.Dispose();
}
}
public async Task<List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default)
{
var query = "NetworkStorage/folderContents";
if (!string.IsNullOrEmpty(path))
{
query += "?path=" + Uri.EscapeDataString(path);
}
HttpResponseMessage? response = null;
try
{
response = await ExecuteAsync(HttpMethod.Get, query, null, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
_logger.Warning(
"ListNetworkStorageFolder({Path}): {Status} (X-Status-Reason={Reason}) body={Body}",
path, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
return new List<NetworkStorageItem>();
}
var json = await ReadBodyAsync(response).ConfigureAwait(false);
var items = JsonSerializer.Deserialize<List<NetworkStorageItem>>(json, JsonOptions);
_logger.Information("ListNetworkStorageFolder({Path}) → {Count} entries", path, items?.Count ?? 0);
return items ?? new List<NetworkStorageItem>();
}
catch (Exception ex)
{
_logger.Warning(ex, "ListNetworkStorageFolderAsync({Path}) failed", path);
return new List<NetworkStorageItem>();
}
finally
{
response?.Dispose();
}
}
public async Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(attachmentId)) throw new ArgumentException("attachmentId required", nameof(attachmentId));
if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentException("targetPath required", nameof(targetPath));
var payload = new Dictionary<string, object?>
{
["attachmentId"] = attachmentId,
["path"] = targetPath
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
_logger.Information(
"UploadToNetworkStorage POST /NetworkStorage/action/upload (attachmentId={AttachmentId}, path={Path})",
attachmentId, targetPath);
var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/upload", json, cancellationToken).ConfigureAwait(false);
try
{
if (!response.IsSuccessStatusCode)
{
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
_logger.Warning(
"UploadToNetworkStorage failed: {Status} (X-Status-Reason={Reason}) body={Body}",
response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
}
throw new EspoCrmHttpException(response.StatusCode, body,
$"NetworkStorage upload returned {(int)response.StatusCode} {response.StatusCode}" +
(string.IsNullOrEmpty(reason) || reason == "(none)" ? "" : " (reason: " + reason + ")"));
}
}
finally
{
response.Dispose();
}
}
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
{
try
@@ -8,15 +8,21 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public interface IAttachmentSaveService
{
/// <summary>
/// Uploads each attachment to EspoCRM and creates a Document linked
/// to the given target (typically a Case). Folder is optional —
/// pass null to leave the document at the root of the Documents
/// module.
/// Uploads each attachment as an EspoCRM Attachment, then asks
/// Marcus-Law's NetworkStorage integration to place the file under
/// <paramref name="targetFolderPath"/> on the shared storage. No
/// Document entity is created — these are raw files dropped into
/// the case's filesystem folder.
/// </summary>
/// <param name="attachments">files to save</param>
/// <param name="targetFolderPath">
/// Full relative folder path on storage, e.g.
/// <c>"30-מרדכי שמביקו/אסמכתאות"</c>. The case folder + optional
/// subfolder. Filename is appended internally.
/// </param>
Task<AttachmentSaveSummary> SaveAsync(
IReadOnlyList<EspoAttachment> attachments,
FilingTarget target,
string? folderId,
string targetFolderPath,
CancellationToken cancellationToken = default);
}
@@ -59,7 +65,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public AttachmentSaveOutcome Outcome { get; set; }
public string? DocumentId { get; set; }
public string? AttachmentId { get; set; }
public string? ErrorMessage { get; set; }
}
@@ -20,5 +20,11 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
Task<System.Collections.Generic.List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default);
// Marcus-Law NetworkStorageIntegration endpoints (filesystem-based
// folder structure under the case folder).
Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default);
Task<System.Collections.Generic.List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default);
Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default);
}
}
@@ -153,9 +153,8 @@
<DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center"
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
<ComboBox ItemsSource="{Binding Folders}"
SelectedItem="{Binding SelectedFolder}"
DisplayMemberPath="Name"
<ComboBox ItemsSource="{Binding Subfolders}"
SelectedItem="{Binding SelectedSubfolder}"
Padding="6,4" />
</DockPanel>
@@ -11,8 +11,24 @@ 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;
@@ -24,14 +40,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private CaseEntity? selectedCase;
[ObservableProperty]
private DocumentFolder? selectedFolder;
private string? selectedSubfolder;
[ObservableProperty]
private string? statusMessage;
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public ObservableCollection<DocumentFolder> Folders { get; } = new ObservableCollection<DocumentFolder>();
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
@@ -50,19 +66,17 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
public FilingTarget? Target
{
get
{
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return null;
return new FilingTarget("Case", SelectedCase.Id, SelectedCase.Name ?? SelectedCase.Id);
}
}
public string? FolderId =>
SelectedFolder == null || string.IsNullOrWhiteSpace(SelectedFolder.Id)
/// <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
: SelectedFolder.Id;
: SelectedSubfolder;
public CaseEntity? PickedCase => SelectedCase;
public event EventHandler? RequestClose;
@@ -74,10 +88,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Sentinel "no folder" row at the top — Id stays null so the
// service skips emitting folderId in the payload.
Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" });
SelectedFolder = Folders[0];
ResetSubfolders();
foreach (var att in attachments)
{
@@ -85,6 +96,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
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();
@@ -95,43 +114,95 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
partial void OnSelectedCaseChanged(CaseEntity? value)
{
SubmitCommand.NotifyCanExecuteChanged();
// Folders are scoped to the chosen case — refresh whenever the
// case selection changes.
_ = LoadFoldersForCaseAsync(value?.Id);
_ = RefreshSubfoldersForSelectedCaseAsync();
}
public async Task LoadFoldersForCaseAsync(string? caseId)
/// <summary>
/// Refreshes the subfolder dropdown to merge static defaults with
/// the actual folders that already exist on the network share for
/// the chosen case. Triggers when the case selection changes.
/// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync()
{
// Reset to the sentinel row first; we'll append folders below.
Folders.Clear();
Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" });
SelectedFolder = Folders[0];
ResetSubfolders();
var c = SelectedCase;
if (c == null || string.IsNullOrEmpty(c.Id)) return;
if (string.IsNullOrWhiteSpace(caseId)) return;
// Best-effort: ensure standard subfolders physically exist on
// disk before the user picks one.
try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
// Fetch the actual case folder path from the server (the
// integration's hook sets it on Case.networkStorageFolderPath
// after the first document save).
try
{
var detail = await _client.GetCaseAsync(
c.Id, "id,name,number,networkStorageFolderPath", default).ConfigureAwait(true);
c.Number = detail.Number;
c.Name = detail.Name;
c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath;
}
catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); }
var folderPath = ResolveCaseFolderPath(c);
if (string.IsNullOrEmpty(folderPath)) return;
try
{
// First try: folders that are already in use for THIS case
// (the user's mental model — most relevant suggestions).
var caseFolders = await _client.ListFoldersUsedInCaseAsync(caseId!).ConfigureAwait(true);
if (caseFolders.Count > 0)
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
foreach (var item in items)
{
foreach (var folder in caseFolders) Folders.Add(folder);
return;
if (!item.IsFolder) continue;
if (Subfolders.Contains(item.Name)) continue; // already in defaults
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
Subfolders.Add(item.Name);
}
// Fallback: list every DocumentFolder in the tenant, so the
// user still has somewhere to put the file even on the very
// first upload to a case.
var global = await _client.ListDocumentFoldersAsync().ConfigureAwait(true);
foreach (var folder in global.List) Folders.Add(folder);
}
catch (Exception ex)
{
_logger.Warning(ex, "SaveAttachmentsViewModel.LoadFoldersForCaseAsync failed for {CaseId}", caseId);
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
}
}
public static string ResolveCaseFolderPath(CaseEntity c)
{
if (c == null) return string.Empty;
if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath))
{
return c.NetworkStorageFolderPath!.TrimEnd('/');
}
// Best-effort match to NetworkDocumentService::buildEntityFolderName:
// "<number>-<primary contact name or case name>", sanitized.
var contactName = c.Name ?? string.Empty;
var raw = !string.IsNullOrWhiteSpace(c.Number)
? $"{c.Number}-{contactName}"
: contactName;
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 SearchAsync(string query, CancellationToken ct)
{
try
@@ -154,6 +225,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
foreach (var hit in search.List)
{
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
// Bare id+name in the list; the full Case (with number +
// networkStorageFolderPath) is hydrated when the user
// picks one.
Cases.Add(new CaseEntity
{
Id = hit.Id,
+31 -10
View File
@@ -482,26 +482,47 @@ namespace OutlookAddin.Services
}
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
// Folders are loaded lazily when the user picks a case — the
// sentinel "(בלי תיקייה)" row is already present.
// Subfolders are populated lazily once the user picks a case;
// the default static set is already pre-loaded.
var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog);
var ok = dialog.ShowDialog();
if (ok != true) return;
var target = vm.Target;
var picked = vm.PickedCase;
var selected = vm.SelectedAttachments;
if (target == null || selected.Count == 0) return;
if (picked == null || string.IsNullOrEmpty(picked.Id) || selected.Count == 0) return;
var caseFolder = SaveAttachmentsViewModel.ResolveCaseFolderPath(picked);
if (string.IsNullOrEmpty(caseFolder))
{
MessageBox.Show(
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
var subfolder = vm.ChosenSubfolderName;
var targetFolderPath = string.IsNullOrEmpty(subfolder)
? caseFolder
: caseFolder + "/" + subfolder;
try
{
Logger.Information(
"Saving {Count} attachment(s) to {Type}/{Id} ({Name}), folder={FolderId}",
selected.Count, target.ParentType, target.ParentId, target.ParentName, vm.FolderId ?? "(none)");
"Saving {Count} attachment(s) to Case/{Id} ({Name}) → {Path}",
selected.Count, picked.Id, picked.Name, targetFolderPath);
var summary = await AttachmentSaveService!.SaveAsync(selected, target, vm.FolderId).ConfigureAwait(true);
ShowAttachmentSummary(summary, target);
// Safety net: ensure the case folder + defaults exist on
// disk before the per-file uploads (the dialog may have
// been submitted before the prior ensure call finished).
try { await CrmClient!.EnsureCaseSubfoldersAsync(picked.Id).ConfigureAwait(true); }
catch (Exception ex) { Logger.Warning(ex, "Pre-upload ensure failed; continuing anyway"); }
var summary = await AttachmentSaveService!.SaveAsync(selected, targetFolderPath).ConfigureAwait(true);
ShowAttachmentSummary(summary, picked.Name ?? picked.Id, targetFolderPath);
}
catch (Exception ex)
{
@@ -514,7 +535,7 @@ namespace OutlookAddin.Services
}
}
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, FilingTarget target)
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
{
string message;
MessageBoxImage icon;
@@ -526,7 +547,7 @@ namespace OutlookAddin.Services
else
{
var parts = new List<string>();
if (summary.SavedCount > 0) parts.Add($"נשמרו {summary.SavedCount} קבצים אל \"{target.ParentName}\"");
if (summary.SavedCount > 0) parts.Add($"נשמרו {summary.SavedCount} קבצים אל \"{caseLabel}\" ({targetPath})");
if (summary.FailedCount > 0) parts.Add($"{summary.FailedCount} נכשלו");
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;