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")]
|
[JsonPropertyName("createdAt")]
|
||||||
public DateTimeOffset? CreatedAt { get; set; }
|
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(
|
public async Task<AttachmentSaveSummary> SaveAsync(
|
||||||
IReadOnlyList<EspoAttachment> attachments,
|
IReadOnlyList<EspoAttachment> attachments,
|
||||||
FilingTarget target,
|
string targetFolderPath,
|
||||||
string? folderId,
|
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (attachments == null) throw new ArgumentNullException(nameof(attachments));
|
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();
|
var summary = new AttachmentSaveSummary();
|
||||||
if (attachments.Count == 0) return summary;
|
if (attachments.Count == 0) return summary;
|
||||||
|
|
||||||
|
var trimmedFolder = targetFolderPath.TrimEnd('/');
|
||||||
|
|
||||||
foreach (var att in attachments)
|
foreach (var att in attachments)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
@@ -47,21 +48,26 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Step 1: create the EspoCRM Attachment (binary stored
|
||||||
|
// in data/upload/<id>; not yet on the network share).
|
||||||
var uploaded = await _client.UploadAttachmentAsync(
|
var uploaded = await _client.UploadAttachmentAsync(
|
||||||
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
|
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var doc = await _client.CreateDocumentAsync(
|
// Step 2: ask Marcus-Law's NetworkStorageIntegration
|
||||||
att.Name, uploaded.Id, target.ParentType, target.ParentId, folderId, cancellationToken).ConfigureAwait(false);
|
// 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(
|
_logger.Information(
|
||||||
"Saved attachment {Name} → Document/{DocId} (parent={ParentType}/{ParentId}, folder={FolderId})",
|
"Saved attachment {Name} → {Path} (attachmentId={AttachmentId})",
|
||||||
att.Name, doc.Id, target.ParentType, target.ParentId, folderId ?? "(none)");
|
att.Name, trimmedFolder, uploaded.Id);
|
||||||
|
|
||||||
summary.Items.Add(new AttachmentSaveItemResult
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
{
|
{
|
||||||
FileName = att.Name,
|
FileName = att.Name,
|
||||||
Outcome = AttachmentSaveOutcome.Saved,
|
Outcome = AttachmentSaveOutcome.Saved,
|
||||||
DocumentId = doc.Id
|
AttachmentId = uploaded.Id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (EspoCrmAuthorizationException ex)
|
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)
|
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -8,15 +8,21 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
public interface IAttachmentSaveService
|
public interface IAttachmentSaveService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Uploads each attachment to EspoCRM and creates a Document linked
|
/// Uploads each attachment as an EspoCRM Attachment, then asks
|
||||||
/// to the given target (typically a Case). Folder is optional —
|
/// Marcus-Law's NetworkStorage integration to place the file under
|
||||||
/// pass null to leave the document at the root of the Documents
|
/// <paramref name="targetFolderPath"/> on the shared storage. No
|
||||||
/// module.
|
/// Document entity is created — these are raw files dropped into
|
||||||
|
/// the case's filesystem folder.
|
||||||
/// </summary>
|
/// </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(
|
Task<AttachmentSaveSummary> SaveAsync(
|
||||||
IReadOnlyList<EspoAttachment> attachments,
|
IReadOnlyList<EspoAttachment> attachments,
|
||||||
FilingTarget target,
|
string targetFolderPath,
|
||||||
string? folderId,
|
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +65,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
|
|
||||||
public AttachmentSaveOutcome Outcome { get; set; }
|
public AttachmentSaveOutcome Outcome { get; set; }
|
||||||
|
|
||||||
public string? DocumentId { get; set; }
|
public string? AttachmentId { get; set; }
|
||||||
|
|
||||||
public string? ErrorMessage { 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<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
|
||||||
Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
|
Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
|
||||||
Task<System.Collections.Generic.List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, 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">
|
<DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center"
|
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center"
|
||||||
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
|
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
|
||||||
<ComboBox ItemsSource="{Binding Folders}"
|
<ComboBox ItemsSource="{Binding Subfolders}"
|
||||||
SelectedItem="{Binding SelectedFolder}"
|
SelectedItem="{Binding SelectedSubfolder}"
|
||||||
DisplayMemberPath="Name"
|
|
||||||
Padding="6,4" />
|
Padding="6,4" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,24 @@ using Serilog;
|
|||||||
|
|
||||||
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
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
|
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 IEspoCrmClient _client;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private CancellationTokenSource? _searchCts;
|
private CancellationTokenSource? _searchCts;
|
||||||
@@ -24,14 +40,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
private CaseEntity? selectedCase;
|
private CaseEntity? selectedCase;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private DocumentFolder? selectedFolder;
|
private string? selectedSubfolder;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? statusMessage;
|
private string? statusMessage;
|
||||||
|
|
||||||
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
|
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>();
|
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
|
||||||
|
|
||||||
@@ -50,19 +66,17 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public FilingTarget? Target
|
/// <summary>
|
||||||
{
|
/// Subfolder string the user picked, or null to save to the case
|
||||||
get
|
/// folder root.
|
||||||
{
|
/// </summary>
|
||||||
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return null;
|
public string? ChosenSubfolderName =>
|
||||||
return new FilingTarget("Case", SelectedCase.Id, SelectedCase.Name ?? SelectedCase.Id);
|
string.IsNullOrEmpty(SelectedSubfolder) ||
|
||||||
}
|
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
|
||||||
}
|
|
||||||
|
|
||||||
public string? FolderId =>
|
|
||||||
SelectedFolder == null || string.IsNullOrWhiteSpace(SelectedFolder.Id)
|
|
||||||
? null
|
? null
|
||||||
: SelectedFolder.Id;
|
: SelectedSubfolder;
|
||||||
|
|
||||||
|
public CaseEntity? PickedCase => SelectedCase;
|
||||||
|
|
||||||
public event EventHandler? RequestClose;
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
@@ -74,10 +88,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
// Sentinel "no folder" row at the top — Id stays null so the
|
ResetSubfolders();
|
||||||
// service skips emitting folderId in the payload.
|
|
||||||
Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" });
|
|
||||||
SelectedFolder = Folders[0];
|
|
||||||
|
|
||||||
foreach (var att in attachments)
|
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)
|
partial void OnSearchTextChanged(string value)
|
||||||
{
|
{
|
||||||
_searchCts?.Cancel();
|
_searchCts?.Cancel();
|
||||||
@@ -95,43 +114,95 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
partial void OnSelectedCaseChanged(CaseEntity? value)
|
partial void OnSelectedCaseChanged(CaseEntity? value)
|
||||||
{
|
{
|
||||||
SubmitCommand.NotifyCanExecuteChanged();
|
SubmitCommand.NotifyCanExecuteChanged();
|
||||||
// Folders are scoped to the chosen case — refresh whenever the
|
_ = RefreshSubfoldersForSelectedCaseAsync();
|
||||||
// case selection changes.
|
|
||||||
_ = LoadFoldersForCaseAsync(value?.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
ResetSubfolders();
|
||||||
Folders.Clear();
|
var c = SelectedCase;
|
||||||
Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" });
|
if (c == null || string.IsNullOrEmpty(c.Id)) return;
|
||||||
SelectedFolder = Folders[0];
|
|
||||||
|
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
// First try: folders that are already in use for THIS case
|
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
|
||||||
// (the user's mental model — most relevant suggestions).
|
foreach (var item in items)
|
||||||
var caseFolders = await _client.ListFoldersUsedInCaseAsync(caseId!).ConfigureAwait(true);
|
|
||||||
if (caseFolders.Count > 0)
|
|
||||||
{
|
{
|
||||||
foreach (var folder in caseFolders) Folders.Add(folder);
|
if (!item.IsFolder) continue;
|
||||||
return;
|
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)
|
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)
|
private async Task SearchAsync(string query, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -154,6 +225,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
foreach (var hit in search.List)
|
foreach (var hit in search.List)
|
||||||
{
|
{
|
||||||
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
|
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
|
Cases.Add(new CaseEntity
|
||||||
{
|
{
|
||||||
Id = hit.Id,
|
Id = hit.Id,
|
||||||
|
|||||||
@@ -482,26 +482,47 @@ namespace OutlookAddin.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
|
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
|
||||||
// Folders are loaded lazily when the user picks a case — the
|
// Subfolders are populated lazily once the user picks a case;
|
||||||
// sentinel "(בלי תיקייה)" row is already present.
|
// the default static set is already pre-loaded.
|
||||||
|
|
||||||
var dialog = new SaveAttachmentsDialog(vm);
|
var dialog = new SaveAttachmentsDialog(vm);
|
||||||
AttachOwnerToOutlook(dialog);
|
AttachOwnerToOutlook(dialog);
|
||||||
var ok = dialog.ShowDialog();
|
var ok = dialog.ShowDialog();
|
||||||
if (ok != true) return;
|
if (ok != true) return;
|
||||||
|
|
||||||
var target = vm.Target;
|
var picked = vm.PickedCase;
|
||||||
var selected = vm.SelectedAttachments;
|
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
|
try
|
||||||
{
|
{
|
||||||
Logger.Information(
|
Logger.Information(
|
||||||
"Saving {Count} attachment(s) to {Type}/{Id} ({Name}), folder={FolderId}",
|
"Saving {Count} attachment(s) to Case/{Id} ({Name}) → {Path}",
|
||||||
selected.Count, target.ParentType, target.ParentId, target.ParentName, vm.FolderId ?? "(none)");
|
selected.Count, picked.Id, picked.Name, targetFolderPath);
|
||||||
|
|
||||||
var summary = await AttachmentSaveService!.SaveAsync(selected, target, vm.FolderId).ConfigureAwait(true);
|
// Safety net: ensure the case folder + defaults exist on
|
||||||
ShowAttachmentSummary(summary, target);
|
// 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)
|
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;
|
string message;
|
||||||
MessageBoxImage icon;
|
MessageBoxImage icon;
|
||||||
@@ -526,7 +547,7 @@ namespace OutlookAddin.Services
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var parts = new List<string>();
|
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} נכשלו");
|
if (summary.FailedCount > 0) parts.Add($"{summary.FailedCount} נכשלו");
|
||||||
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
|
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
|
||||||
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
|
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
|
||||||
|
|||||||
Reference in New Issue
Block a user