diff --git a/src/OutlookAddin.Core/Models/CaseEntity.cs b/src/OutlookAddin.Core/Models/CaseEntity.cs
index 80f014c..c73995b 100644
--- a/src/OutlookAddin.Core/Models/CaseEntity.cs
+++ b/src/OutlookAddin.Core/Models/CaseEntity.cs
@@ -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; }
}
}
diff --git a/src/OutlookAddin.Core/Models/NetworkStorageItem.cs b/src/OutlookAddin.Core/Models/NetworkStorageItem.cs
new file mode 100644
index 0000000..9789c65
--- /dev/null
+++ b/src/OutlookAddin.Core/Models/NetworkStorageItem.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace MarcusLaw.OutlookAddin.Core.Models
+{
+ ///
+ /// One row from GET /NetworkStorage/folderContents — folder or file.
+ ///
+ 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);
+ }
+}
diff --git a/src/OutlookAddin.Core/Services/AttachmentSaveService.cs b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs
index 7648389..3c5ccaa 100644
--- a/src/OutlookAddin.Core/Services/AttachmentSaveService.cs
+++ b/src/OutlookAddin.Core/Services/AttachmentSaveService.cs
@@ -20,16 +20,17 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public async Task SaveAsync(
IReadOnlyList 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/; 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 .
+ // 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)
diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs
index 11b3076..51a2a29 100644
--- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs
+++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs
@@ -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
+ {
+ ["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> 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();
+ }
+ var json = await ReadBodyAsync(response).ConfigureAwait(false);
+ var items = JsonSerializer.Deserialize>(json, JsonOptions);
+ _logger.Information("ListNetworkStorageFolder({Path}) → {Count} entries", path, items?.Count ?? 0);
+ return items ?? new List();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "ListNetworkStorageFolderAsync({Path}) failed", path);
+ return new List();
+ }
+ 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
+ {
+ ["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> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
{
try
diff --git a/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs
index dc214b8..33440ec 100644
--- a/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs
+++ b/src/OutlookAddin.Core/Services/IAttachmentSaveService.cs
@@ -8,15 +8,21 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public interface IAttachmentSaveService
{
///
- /// 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
+ /// on the shared storage. No
+ /// Document entity is created — these are raw files dropped into
+ /// the case's filesystem folder.
///
+ /// files to save
+ ///
+ /// Full relative folder path on storage, e.g.
+ /// "30-מרדכי שמביקו/אסמכתאות". The case folder + optional
+ /// subfolder. Filename is appended internally.
+ ///
Task SaveAsync(
IReadOnlyList 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; }
}
diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs
index 73585cd..b504ec2 100644
--- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs
+++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs
@@ -20,5 +20,11 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
Task> 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> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default);
+ Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default);
}
}
diff --git a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml
index c51296c..e199c7e 100644
--- a/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml
+++ b/src/OutlookAddin.UI/Dialogs/SaveAttachmentsDialog.xaml
@@ -153,9 +153,8 @@
-
diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
index 5021f97..129f3a2 100644
--- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
+++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs
@@ -11,8 +11,24 @@ 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;
@@ -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 Cases { get; } = new ObservableCollection();
- public ObservableCollection Folders { get; } = new ObservableCollection();
+ public ObservableCollection Subfolders { get; } = new ObservableCollection();
public ObservableCollection Attachments { get; } = new ObservableCollection();
@@ -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)
+ ///
+ /// 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
- : 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)
+ ///
+ /// 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.
+ ///
+ 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:
+ // "-", sanitized.
+ var contactName = c.Name ?? string.Empty;
+ var raw = !string.IsNullOrWhiteSpace(c.Number)
+ ? $"{c.Number}-{contactName}"
+ : contactName;
+ 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 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,
diff --git a/src/OutlookAddin/Services/AddInHost.cs b/src/OutlookAddin/Services/AddInHost.cs
index a389c72..238d46f 100644
--- a/src/OutlookAddin/Services/AddInHost.cs
+++ b/src/OutlookAddin/Services/AddInHost.cs
@@ -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();
- 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;