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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user