Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5242abb585 | |||
| 037d573e6e | |||
| cb7d530b4c | |||
| 7924cda520 | |||
| 7ee9a00fc4 | |||
| af8cf13443 | |||
| 26322c92ff | |||
| c98fee7b5b | |||
| 1a9ca2d2ad | |||
| b1f8e3e50b | |||
| 0fea393b81 | |||
| 46955e2064 |
+10
-2
@@ -1,4 +1,4 @@
|
||||
# outlook-addin-cdn — serves ClickOnce artifacts at platform.dev.marcus-law.co.il/outlook-addin/*
|
||||
# outlook-addin-cdn -- serves ClickOnce artifacts at platform.dev.marcus-law.co.il/outlook-addin/*
|
||||
#
|
||||
# The publish/ directory (produced by the Windows job) is copied into the image
|
||||
# along with a custom nginx.conf that maps the ClickOnce-specific MIME types.
|
||||
@@ -10,6 +10,11 @@ FROM nginx:1.27-alpine
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/outlook-addin.conf
|
||||
|
||||
# Clear the stock nginx html dir so its 50x.html/index.html don't end up
|
||||
# alongside our ClickOnce manifests.
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Coolify adds a StripPrefix middleware for the /outlook-addin path, so nginx
|
||||
# sees requests at the root (/OutlookAddin.vsto, etc) -- copy files to the
|
||||
# nginx root accordingly.
|
||||
@@ -20,5 +25,8 @@ RUN chmod -R a+rX /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Use 127.0.0.1 explicitly -- busybox wget resolves "localhost" to ::1 first,
|
||||
# but our nginx `listen 80` is IPv4-only, so localhost would refuse the
|
||||
# connection and the container would be flagged unhealthy.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost/health || exit 1
|
||||
CMD wget -qO- http://127.0.0.1/health || exit 1
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Response from POST /api/v1/Attachment — the server returns at least
|
||||
/// the new attachment's id, which we then thread into a Document create.
|
||||
/// </summary>
|
||||
public sealed class AttachmentEntity
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||
{
|
||||
public sealed class DocumentEntity
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("fileId")]
|
||||
public string? FileId { get; set; }
|
||||
|
||||
[JsonPropertyName("folderId")]
|
||||
public string? FolderId { get; set; }
|
||||
|
||||
[JsonPropertyName("folderName")]
|
||||
public string? FolderName { get; set; }
|
||||
|
||||
[JsonPropertyName("parentType")]
|
||||
public string? ParentType { get; set; }
|
||||
|
||||
[JsonPropertyName("parentId")]
|
||||
public string? ParentId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A row from /api/v1/DocumentFolder — used to populate the folder
|
||||
/// dropdown in the "Save attachments" dialog. EspoCRM document folders
|
||||
/// are organizational labels independent of the document's parent link.
|
||||
/// </summary>
|
||||
public sealed class DocumentFolder
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("parentId")]
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
[JsonPropertyName("order")]
|
||||
public int? Order { 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MarcusLaw.OutlookAddin.Core.Models;
|
||||
using Serilog;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
{
|
||||
public sealed class AttachmentSaveService : IAttachmentSaveService
|
||||
{
|
||||
private readonly IEspoCrmClient _client;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public AttachmentSaveService(IEspoCrmClient client, ILogger logger)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<AttachmentSaveSummary> SaveAsync(
|
||||
IReadOnlyList<EspoAttachment> attachments,
|
||||
string targetFolderPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (attachments == null) throw new ArgumentNullException(nameof(attachments));
|
||||
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();
|
||||
|
||||
if (summary.AuthRequired)
|
||||
{
|
||||
summary.Items.Add(new AttachmentSaveItemResult
|
||||
{
|
||||
FileName = att.Name,
|
||||
Outcome = AttachmentSaveOutcome.AuthRequired,
|
||||
ErrorMessage = "Skipped — earlier upload rejected by EspoCRM"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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} → {Path} (attachmentId={AttachmentId})",
|
||||
att.Name, trimmedFolder, uploaded.Id);
|
||||
|
||||
summary.Items.Add(new AttachmentSaveItemResult
|
||||
{
|
||||
FileName = att.Name,
|
||||
Outcome = AttachmentSaveOutcome.Saved,
|
||||
AttachmentId = uploaded.Id
|
||||
});
|
||||
}
|
||||
catch (EspoCrmAuthorizationException ex)
|
||||
{
|
||||
_logger.Warning(ex, "Attachment save aborted: EspoCRM rejected credentials");
|
||||
summary.AuthRequired = true;
|
||||
summary.Items.Add(new AttachmentSaveItemResult
|
||||
{
|
||||
FileName = att.Name,
|
||||
Outcome = AttachmentSaveOutcome.AuthRequired,
|
||||
ErrorMessage = ex.Message
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Attachment save failed for {Name}", att.Name);
|
||||
summary.Items.Add(new AttachmentSaveItemResult
|
||||
{
|
||||
FileName = att.Name,
|
||||
Outcome = AttachmentSaveOutcome.Failed,
|
||||
ErrorMessage = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,320 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
||||
|
||||
public async Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName));
|
||||
if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content));
|
||||
|
||||
var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||
var dataUri = "data:" + mime + ";base64," + base64Content;
|
||||
// Per the official EspoCRM API docs (POST /Attachment), the binary
|
||||
// is sent in the "file" field as a data-URI. The previous extra
|
||||
// "contents" alias was an undocumented legacy that strict tenants
|
||||
// reject with 400.
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = fileName,
|
||||
["type"] = mime,
|
||||
["role"] = "Attachment",
|
||||
["relatedType"] = "Document",
|
||||
["field"] = "file",
|
||||
["file"] = dataUri
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
_logger.Information(
|
||||
"UploadAttachment POST /Attachment (name={Name}, type={Type}, base64Length={Length})",
|
||||
fileName, mime, base64Content.Length);
|
||||
|
||||
var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
var reason = string.Join(",", response.Headers.TryGetValues("X-Status-Reason", out var v) ? v : Array.Empty<string>());
|
||||
_logger.Warning(
|
||||
"UploadAttachment failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
||||
// 401 only = auth. 403 here is content/permission, not creds.
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new EspoCrmAuthorizationException(
|
||||
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
||||
}
|
||||
throw new EspoCrmHttpException(response.StatusCode, errBody,
|
||||
$"EspoCRM Attachment upload returned {(int)response.StatusCode} {response.StatusCode}" +
|
||||
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
|
||||
}
|
||||
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<AttachmentEntity>(body, JsonOptions)
|
||||
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
|
||||
if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId));
|
||||
|
||||
// Marcus-Law's EspoCRM has `publishDate` configured as required on
|
||||
// Document. The field accepts a plain date (YYYY-MM-DD). Default
|
||||
// to today; the user can edit it later in EspoCRM if needed.
|
||||
var todayIso = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = name,
|
||||
["fileId"] = attachmentFileId,
|
||||
["status"] = "Active",
|
||||
["publishDate"] = todayIso
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType;
|
||||
if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
|
||||
if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId;
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
_logger.Information(
|
||||
"CreateDocument POST /Document (name={Name}, fileId={FileId}, parent={ParentType}/{ParentId}, folder={FolderId})",
|
||||
name, attachmentFileId, parentType, parentId, folderId ?? "(none)");
|
||||
|
||||
var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
||||
? string.Join(",", v) : "(none)";
|
||||
_logger.Warning(
|
||||
"CreateDocument failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new EspoCrmAuthorizationException(
|
||||
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
||||
}
|
||||
throw new EspoCrmHttpException(response.StatusCode, errBody,
|
||||
$"EspoCRM Document create returned {(int)response.StatusCode} {response.StatusCode}" +
|
||||
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
|
||||
}
|
||||
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
return JsonSerializer.Deserialize<DocumentEntity>(body, JsonOptions)
|
||||
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(caseId)) return new List<DocumentFolder>();
|
||||
|
||||
// Some EspoCRM installs reject filtering by polymorphic parentType
|
||||
// with a 400. ParentId is a regular string field, and an ID alone
|
||||
// is unique enough — we'll filter just by that. We additionally
|
||||
// verify parentType client-side to be safe.
|
||||
var path =
|
||||
"Document?select=" + Uri.EscapeDataString("id,folderId,folderName,parentType") +
|
||||
"&where[0][type]=equals" +
|
||||
"&where[0][attribute]=parentId" +
|
||||
"&where[0][value]=" + Uri.EscapeDataString(caseId) +
|
||||
"&maxSize=200";
|
||||
|
||||
// Hand-rolled error capture (instead of GetJsonAsync) so a 400 from
|
||||
// a picky tenant ends up in the log with body + X-Status-Reason.
|
||||
HttpResponseMessage? response = null;
|
||||
try
|
||||
{
|
||||
response = await ExecuteAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
||||
? string.Join(",", v) : "(none)";
|
||||
_logger.Warning(
|
||||
"ListFoldersUsedInCase({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||
caseId, response.StatusCode, reason,
|
||||
string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
||||
return new List<DocumentFolder>();
|
||||
}
|
||||
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
var parsed = JsonSerializer.Deserialize<EspoListResponse<DocumentEntity>>(body, JsonOptions)
|
||||
?? new EspoListResponse<DocumentEntity>();
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var folders = new List<DocumentFolder>();
|
||||
foreach (var doc in parsed.List)
|
||||
{
|
||||
// Defensive: if the server returns docs from other parents
|
||||
// (shouldn't, but ID collisions are theoretically possible),
|
||||
// drop them.
|
||||
if (!string.IsNullOrEmpty(doc.ParentType) &&
|
||||
!string.Equals(doc.ParentType, "Case", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrEmpty(doc.FolderId)) continue;
|
||||
if (!seen.Add(doc.FolderId!)) continue;
|
||||
folders.Add(new DocumentFolder
|
||||
{
|
||||
Id = doc.FolderId!,
|
||||
Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName!
|
||||
});
|
||||
}
|
||||
_logger.Information(
|
||||
"ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders",
|
||||
caseId, parsed.List.Count, folders.Count);
|
||||
return folders;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId);
|
||||
return new List<DocumentFolder>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
var result = await GetJsonAsync<EspoListResponse<DocumentFolder>>(
|
||||
"DocumentFolder?orderBy=name&order=asc&maxSize=200",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_logger.Information(
|
||||
"ListDocumentFoldersAsync → {Count} folders in tenant",
|
||||
result?.List?.Count ?? 0);
|
||||
return result ?? new EspoListResponse<DocumentFolder>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list");
|
||||
return new EspoListResponse<DocumentFolder>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
|
||||
@@ -343,14 +657,20 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
|
||||
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
|
||||
// Only 401 is unambiguously an authentication problem. 403 in
|
||||
// EspoCRM is "you are who you say you are, but THIS operation
|
||||
// isn't allowed" — content type rejected, ACL miss, required
|
||||
// role missing, etc. Conflating them aborts whole batches.
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
throw new EspoCrmAuthorizationException(
|
||||
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
||||
}
|
||||
|
||||
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
||||
? " (reason: " + string.Join(",", v) + ")" : string.Empty;
|
||||
throw new EspoCrmHttpException(response.StatusCode, body,
|
||||
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}: {Truncate(body, 500)}");
|
||||
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}{reason}: {Truncate(body, 500)}");
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpResponseMessage response)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MarcusLaw.OutlookAddin.Core.Models;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
{
|
||||
public interface IAttachmentSaveService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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,
|
||||
string targetFolderPath,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class AttachmentSaveSummary
|
||||
{
|
||||
public List<AttachmentSaveItemResult> Items { get; set; } = new List<AttachmentSaveItemResult>();
|
||||
|
||||
public bool AuthRequired { get; set; }
|
||||
|
||||
public int SavedCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var it in Items)
|
||||
{
|
||||
if (it.Outcome == AttachmentSaveOutcome.Saved) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
public int FailedCount
|
||||
{
|
||||
get
|
||||
{
|
||||
var n = 0;
|
||||
foreach (var it in Items)
|
||||
{
|
||||
if (it.Outcome == AttachmentSaveOutcome.Failed) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AttachmentSaveItemResult
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
public AttachmentSaveOutcome Outcome { get; set; }
|
||||
|
||||
public string? AttachmentId { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public enum AttachmentSaveOutcome
|
||||
{
|
||||
Saved,
|
||||
Failed,
|
||||
AuthRequired
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,16 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
|
||||
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
|
||||
Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.SaveAttachmentsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="שמור קבצים מצורפים ל-Klear"
|
||||
Height="560" Width="620"
|
||||
MinHeight="420" MinWidth="500"
|
||||
FlowDirection="RightToLeft"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False"
|
||||
ResizeMode="CanResize"
|
||||
Background="#F8FAFC"
|
||||
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
|
||||
<Window.Resources>
|
||||
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
|
||||
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
|
||||
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
|
||||
<SolidColorBrush x:Key="Muted" Color="#64748B" />
|
||||
<SolidColorBrush x:Key="CardBg" Color="White" />
|
||||
<SolidColorBrush x:Key="HoverBg" Color="#F1F5F9" />
|
||||
<SolidColorBrush x:Key="SelectedBg" Color="#DBEAFE" />
|
||||
|
||||
<Style x:Key="Card" TargetType="Border">
|
||||
<Setter Property="Background" Value="{StaticResource CardBg}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource Primary}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="22,8" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="MinWidth" Value="110" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource PrimaryHover}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Background" Value="#94A3B8" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="Foreground" Value="#0F172A" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="20,8" />
|
||||
<Setter Property="MinWidth" Value="110" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBg}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="CaseItem" TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4"
|
||||
Margin="2,1" Padding="8,6">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBg}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectedBg}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Search -->
|
||||
<Border Grid.Row="0" Style="{StaticResource Card}" Padding="10,8" Margin="0,0,0,8">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Left" Text="🔍" VerticalAlignment="Center" FontSize="16" Margin="0,0,8,0" />
|
||||
<TextBox x:Name="SearchBox"
|
||||
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
|
||||
BorderThickness="0"
|
||||
Background="Transparent"
|
||||
FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Cases -->
|
||||
<Border Grid.Row="1" Style="{StaticResource Card}">
|
||||
<ListBox ItemsSource="{Binding Cases}"
|
||||
SelectedItem="{Binding SelectedCase}"
|
||||
BorderThickness="0"
|
||||
Background="Transparent"
|
||||
ItemContainerStyle="{StaticResource CaseItem}"
|
||||
Padding="4">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel>
|
||||
<TextBlock FontSize="13">
|
||||
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
|
||||
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Border>
|
||||
|
||||
<!-- Folder -->
|
||||
<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 Subfolders}"
|
||||
SelectedItem="{Binding SelectedSubfolder}"
|
||||
Padding="6,4" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Attachments header -->
|
||||
<TextBlock Grid.Row="3" Margin="0,12,0,4" Foreground="{StaticResource Muted}" FontSize="12">
|
||||
<Run Text="קבצים מצורפים (" /><Run Text="{Binding Attachments.Count, Mode=OneWay}" /><Run Text=")" />
|
||||
</TextBlock>
|
||||
|
||||
<!-- Attachments list -->
|
||||
<Border Grid.Row="4" Style="{StaticResource Card}" MaxHeight="160">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Attachments}" Margin="6">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,3" LastChildFill="True">
|
||||
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding SizeDisplay}"
|
||||
DockPanel.Dock="Left"
|
||||
Foreground="{StaticResource Muted}"
|
||||
FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
Margin="8,0,0,0" />
|
||||
<TextBlock Text="{Binding FileName}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Margin="8,0,0,0" />
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Action row -->
|
||||
<DockPanel Grid.Row="5" Margin="0,12,0,0" LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right"
|
||||
Text="{Binding StatusMessage}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"
|
||||
Foreground="{StaticResource Muted}"
|
||||
FontSize="12"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||
<Button Command="{Binding SubmitCommand}"
|
||||
IsDefault="True"
|
||||
Content="שמור"
|
||||
Style="{StaticResource PrimaryButton}"
|
||||
Margin="0,0,8,0" />
|
||||
<Button Command="{Binding CancelCommand}"
|
||||
IsCancel="True"
|
||||
Content="ביטול"
|
||||
Style="{StaticResource SecondaryButton}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
||||
{
|
||||
public partial class SaveAttachmentsDialog : Window
|
||||
{
|
||||
private SaveAttachmentsViewModel? _viewModel;
|
||||
|
||||
public SaveAttachmentsDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += (_, __) => SearchBox.Focus();
|
||||
}
|
||||
|
||||
public SaveAttachmentsDialog(SaveAttachmentsViewModel viewModel) : this()
|
||||
{
|
||||
AttachViewModel(viewModel);
|
||||
}
|
||||
|
||||
public void AttachViewModel(SaveAttachmentsViewModel viewModel)
|
||||
{
|
||||
if (_viewModel != null) _viewModel.RequestClose -= OnRequestClose;
|
||||
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
|
||||
DataContext = _viewModel;
|
||||
_viewModel.RequestClose += OnRequestClose;
|
||||
}
|
||||
|
||||
private void OnRequestClose(object? sender, EventArgs e)
|
||||
{
|
||||
DialogResult = _viewModel?.DialogResult;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using MarcusLaw.OutlookAddin.Core.Models;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
{
|
||||
public sealed partial class AttachmentRowViewModel : ObservableObject
|
||||
{
|
||||
public EspoAttachment Data { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSelected = true;
|
||||
|
||||
public string FileName => Data.Name;
|
||||
|
||||
public string SizeDisplay { get; }
|
||||
|
||||
public AttachmentRowViewModel(EspoAttachment data)
|
||||
{
|
||||
Data = data;
|
||||
SizeDisplay = ComputeSize(data.ContentBase64);
|
||||
}
|
||||
|
||||
private static string ComputeSize(string base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return string.Empty;
|
||||
// Base64 expands binary 4-for-3, ignoring padding chars.
|
||||
var padding = 0;
|
||||
if (base64.Length > 0 && base64[base64.Length - 1] == '=') padding++;
|
||||
if (base64.Length > 1 && base64[base64.Length - 2] == '=') padding++;
|
||||
var bytes = (long)((base64.Length * 3) / 4) - padding;
|
||||
return FormatBytes(bytes);
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("F1") + " KB";
|
||||
return (bytes / (1024.0 * 1024.0)).ToString("F1") + " MB";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MarcusLaw.OutlookAddin.Core.Models;
|
||||
using MarcusLaw.OutlookAddin.Core.Services;
|
||||
using Serilog;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
{
|
||||
/// <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;
|
||||
|
||||
[ObservableProperty]
|
||||
private string searchText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private CaseEntity? selectedCase;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? selectedSubfolder;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? statusMessage;
|
||||
|
||||
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
|
||||
|
||||
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
|
||||
|
||||
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
|
||||
|
||||
public bool? DialogResult { get; private set; }
|
||||
|
||||
public IReadOnlyList<EspoAttachment> SelectedAttachments
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = new List<EspoAttachment>();
|
||||
foreach (var row in Attachments)
|
||||
{
|
||||
if (row.IsSelected) list.Add(row.Data);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
: SelectedSubfolder;
|
||||
|
||||
public CaseEntity? PickedCase => SelectedCase;
|
||||
|
||||
public event EventHandler? RequestClose;
|
||||
|
||||
public SaveAttachmentsViewModel(
|
||||
IEspoCrmClient client,
|
||||
ILogger logger,
|
||||
IEnumerable<EspoAttachment> attachments)
|
||||
{
|
||||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
ResetSubfolders();
|
||||
|
||||
foreach (var att in attachments)
|
||||
{
|
||||
Attachments.Add(new AttachmentRowViewModel(att));
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetSubfolders()
|
||||
{
|
||||
Subfolders.Clear();
|
||||
Subfolders.Add(RootSentinel);
|
||||
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
|
||||
SelectedSubfolder = Subfolders[0];
|
||||
}
|
||||
|
||||
partial void OnSearchTextChanged(string value)
|
||||
{
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
_ = SearchAsync(value, _searchCts.Token);
|
||||
}
|
||||
|
||||
partial void OnSelectedCaseChanged(CaseEntity? value)
|
||||
{
|
||||
SubmitCommand.NotifyCanExecuteChanged();
|
||||
_ = RefreshSubfoldersForSelectedCaseAsync();
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
ResetSubfolders();
|
||||
var c = SelectedCase;
|
||||
if (c == null || string.IsNullOrEmpty(c.Id)) 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
|
||||
{
|
||||
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
|
||||
foreach (var item in items)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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
|
||||
{
|
||||
await Task.Delay(250, ct).ConfigureAwait(true);
|
||||
|
||||
var trimmed = (query ?? string.Empty).Trim();
|
||||
if (trimmed.Length < 2)
|
||||
{
|
||||
Cases.Clear();
|
||||
StatusMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
Cases.Clear();
|
||||
int kept = 0;
|
||||
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,
|
||||
Name = hit.Name
|
||||
});
|
||||
kept++;
|
||||
}
|
||||
StatusMessage = kept == 0
|
||||
? "אין תוצאות מסוג תיק (Case)"
|
||||
: $"נמצאו {kept} תיקים";
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded */ }
|
||||
catch (EspoCrmAuthorizationException)
|
||||
{
|
||||
StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "SaveAttachments search failed");
|
||||
StatusMessage = "שגיאת חיפוש: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private void Submit()
|
||||
{
|
||||
DialogResult = true;
|
||||
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private bool CanSubmit()
|
||||
{
|
||||
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return false;
|
||||
foreach (var row in Attachments)
|
||||
{
|
||||
if (row.IsSelected) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
DialogResult = false;
|
||||
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 647 B |
@@ -36,7 +36,7 @@
|
||||
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl>
|
||||
<InstallUrl>\\192.168.10.97\Projects\LegalCRM\Add-in\</InstallUrl>
|
||||
<TargetCulture>en</TargetCulture>
|
||||
<ApplicationVersion>1.0.0.4</ApplicationVersion>
|
||||
<ApplicationVersion>1.0.0.5</ApplicationVersion>
|
||||
<AutoIncrementApplicationRevision>true</AutoIncrementApplicationRevision>
|
||||
<UpdateEnabled>true</UpdateEnabled>
|
||||
<UpdateInterval>7</UpdateInterval>
|
||||
@@ -270,6 +270,9 @@
|
||||
<EmbeddedResource Include="Images\klear-compose.png">
|
||||
<LogicalName>OutlookAddin.Images.klear-compose.png</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Images\klear-attach.png">
|
||||
<LogicalName>OutlookAddin.Images.klear-attach.png</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<Compile Include="Services\AddInHost.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
|
||||
@@ -19,7 +19,15 @@ namespace OutlookAddin.Ribbon
|
||||
{
|
||||
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
|
||||
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
|
||||
private const string FileButtonId = "FileToEspoCrm";
|
||||
|
||||
// Every button whose `getEnabled` depends on the current Explorer
|
||||
// selection must be re-evaluated on every SelectionChange — Office
|
||||
// caches the initial result otherwise and the button stays stuck.
|
||||
private static readonly string[] SelectionDependentButtons =
|
||||
{
|
||||
"FileToEspoCrm",
|
||||
"SaveAttachments"
|
||||
};
|
||||
|
||||
private IRibbonUI? _explorerRibbon;
|
||||
private IRibbonUI? _inspectorRibbon;
|
||||
@@ -45,7 +53,7 @@ namespace OutlookAddin.Ribbon
|
||||
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
|
||||
if (explorer != null)
|
||||
{
|
||||
explorer.SelectionChange += () => _explorerRibbon?.InvalidateControl(FileButtonId);
|
||||
explorer.SelectionChange += InvalidateSelectionDependentButtons;
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -54,6 +62,16 @@ namespace OutlookAddin.Ribbon
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateSelectionDependentButtons()
|
||||
{
|
||||
var ribbon = _explorerRibbon;
|
||||
if (ribbon == null) return;
|
||||
foreach (var id in SelectionDependentButtons)
|
||||
{
|
||||
try { ribbon.InvalidateControl(id); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
public void OnInspectorRibbonLoad(IRibbonUI ribbon)
|
||||
{
|
||||
_inspectorRibbon = ribbon;
|
||||
@@ -92,6 +110,43 @@ namespace OutlookAddin.Ribbon
|
||||
Globals.ThisAddIn.AddInHost.ShowSidebar();
|
||||
}
|
||||
|
||||
public bool OnGetSaveAttachmentsEnabled(IRibbonControl control)
|
||||
{
|
||||
// Enabled only when EXACTLY one MailItem is selected AND it has
|
||||
// at least one attachment. Doing multi-mail save would conflict
|
||||
// with the per-file checkbox UX in the dialog.
|
||||
try
|
||||
{
|
||||
var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
|
||||
if (selection == null || selection.Count != 1) return false;
|
||||
if (selection[1] is not Outlook.MailItem mail) return false;
|
||||
var attachments = mail.Attachments;
|
||||
if (attachments == null) return false;
|
||||
return attachments.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSaveAttachments(IRibbonControl control)
|
||||
{
|
||||
try
|
||||
{
|
||||
var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
|
||||
if (selection == null || selection.Count != 1) return;
|
||||
if (selection[1] is Outlook.MailItem mail)
|
||||
{
|
||||
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachments failed");
|
||||
}
|
||||
}
|
||||
|
||||
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
|
||||
{
|
||||
// Show only on outgoing (compose) mail inspectors.
|
||||
@@ -150,6 +205,7 @@ namespace OutlookAddin.Ribbon
|
||||
switch (control.Id)
|
||||
{
|
||||
case "FileToEspoCrm": fileName = "klear-file.png"; break;
|
||||
case "SaveAttachments": fileName = "klear-attach.png"; break;
|
||||
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
|
||||
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
|
||||
case "ComposeFromCase": fileName = "klear-compose.png"; break;
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
getImage="OnGetImage"
|
||||
onAction="OnFileToEspoCrm"
|
||||
getEnabled="OnGetFileToEspoCrmEnabled" />
|
||||
<button id="SaveAttachments"
|
||||
label="שמור קבצים"
|
||||
screentip="שמור את הקבצים המצורפים בלבד"
|
||||
supertip="מעלה את הקבצים המצורפים של המייל הנבחר ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
|
||||
size="large"
|
||||
getImage="OnGetImage"
|
||||
onAction="OnSaveAttachments"
|
||||
getEnabled="OnGetSaveAttachmentsEnabled" />
|
||||
<button id="ShowSidebar"
|
||||
label="פרטי תיק"
|
||||
screentip="הצג את סרגל פרטי התיק"
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace OutlookAddin.Services
|
||||
public IEspoCrmClient? CrmClient { get; private set; }
|
||||
public IFilingService? FilingService { get; private set; }
|
||||
public IMatchingService? MatchingService { get; private set; }
|
||||
public IAttachmentSaveService? AttachmentSaveService { get; private set; }
|
||||
public ComposeService? ComposeService { get; private set; }
|
||||
|
||||
private readonly Outlook.Application _outlookApp;
|
||||
@@ -353,6 +354,7 @@ namespace OutlookAddin.Services
|
||||
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
||||
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
||||
ComposeService = new ComposeService(CrmClient, Logger);
|
||||
AttachmentSaveService = new AttachmentSaveService(CrmClient, Logger);
|
||||
_folderResolver ??= new FolderResolver(_outlookApp, Logger);
|
||||
_folderWatcher = new FolderWatcher(
|
||||
_outlookApp,
|
||||
@@ -387,6 +389,7 @@ namespace OutlookAddin.Services
|
||||
FilingService = null;
|
||||
MatchingService = null;
|
||||
ComposeService = null;
|
||||
AttachmentSaveService = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -428,6 +431,130 @@ namespace OutlookAddin.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save-attachments-only flow. The user clicks the ribbon's "שמור
|
||||
/// קבצים" button while standing on a mail with one or more
|
||||
/// attachments. We extract the attachments (no Email entity
|
||||
/// created), let the user pick a Case + optional DocumentFolder,
|
||||
/// then upload each attachment + create a Document linked to the
|
||||
/// case.
|
||||
/// </summary>
|
||||
public async Task LaunchSaveAttachmentsAsync(Outlook.MailItem mail)
|
||||
{
|
||||
if (mail == null) return;
|
||||
if (!IsConfigured)
|
||||
{
|
||||
var result = MessageBox.Show(
|
||||
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
|
||||
"Marcus-Law OutlookAddin",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Information);
|
||||
if (result == MessageBoxResult.Yes) LaunchSettings();
|
||||
if (!IsConfigured) return;
|
||||
}
|
||||
|
||||
// Extract on the STA — MailItemExtractor handles all COM access
|
||||
// and gives us a self-contained envelope.
|
||||
MailEnvelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = MailItemExtractor.Extract(mail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
|
||||
MessageBox.Show(
|
||||
"קריאת הקבצים המצורפים נכשלה: " + ex.Message,
|
||||
"Marcus-Law OutlookAddin",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.Attachments.Count == 0)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"אין במייל הזה קבצים מצורפים לשמירה.",
|
||||
"Marcus-Law OutlookAddin",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
|
||||
// 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 picked = vm.PickedCase;
|
||||
var selected = vm.SelectedAttachments;
|
||||
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 Case/{Id} ({Name}) → {Path}",
|
||||
selected.Count, picked.Id, picked.Name, targetFolderPath);
|
||||
|
||||
// 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)
|
||||
{
|
||||
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
|
||||
MessageBox.Show(
|
||||
"שמירת הקבצים נכשלה: " + ex.Message,
|
||||
"Marcus-Law OutlookAddin",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
|
||||
{
|
||||
string message;
|
||||
MessageBoxImage icon;
|
||||
if (summary.AuthRequired)
|
||||
{
|
||||
message = "מפתח ה-API נדחה. יש לעדכן את ההגדרות ולנסות שוב.";
|
||||
icon = MessageBoxImage.Warning;
|
||||
}
|
||||
else
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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;
|
||||
}
|
||||
MessageBox.Show(message, "Marcus-Law OutlookAddin", MessageBoxButton.OK, icon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
|
||||
/// Creates a brand new MailItem, populates it with case details, then
|
||||
|
||||
@@ -98,9 +98,11 @@ namespace OutlookAddin.Services
|
||||
{
|
||||
att.SaveAsFile(tempPath);
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
var name = att.FileName ?? "attachment.bin";
|
||||
env.Attachments.Add(new EspoAttachment
|
||||
{
|
||||
Name = att.FileName ?? "attachment.bin",
|
||||
Name = name,
|
||||
ContentType = GuessMimeType(name),
|
||||
ContentBase64 = Convert.ToBase64String(bytes)
|
||||
});
|
||||
}
|
||||
@@ -171,6 +173,70 @@ namespace OutlookAddin.Services
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks a sane MIME type from the file extension. EspoCRM's default
|
||||
/// attachment-upload config rejects "application/octet-stream" with
|
||||
/// "Not allowed file type", so we map common Office / image / archive
|
||||
/// extensions to canonical MIMEs before posting.
|
||||
/// </summary>
|
||||
private static string GuessMimeType(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return "application/octet-stream";
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
switch (ext)
|
||||
{
|
||||
// Office (legacy + OOXML)
|
||||
case ".pdf": return "application/pdf";
|
||||
case ".doc": return "application/msword";
|
||||
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
case ".xls": return "application/vnd.ms-excel";
|
||||
case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
case ".ppt": return "application/vnd.ms-powerpoint";
|
||||
case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||
case ".rtf": return "application/rtf";
|
||||
case ".odt": return "application/vnd.oasis.opendocument.text";
|
||||
case ".ods": return "application/vnd.oasis.opendocument.spreadsheet";
|
||||
|
||||
// Text
|
||||
case ".txt": return "text/plain";
|
||||
case ".csv": return "text/csv";
|
||||
case ".log": return "text/plain";
|
||||
case ".htm":
|
||||
case ".html": return "text/html";
|
||||
case ".xml": return "application/xml";
|
||||
case ".json": return "application/json";
|
||||
|
||||
// Images
|
||||
case ".png": return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg": return "image/jpeg";
|
||||
case ".gif": return "image/gif";
|
||||
case ".bmp": return "image/bmp";
|
||||
case ".tif":
|
||||
case ".tiff": return "image/tiff";
|
||||
case ".webp": return "image/webp";
|
||||
case ".svg": return "image/svg+xml";
|
||||
|
||||
// Archives
|
||||
case ".zip": return "application/zip";
|
||||
case ".rar": return "application/x-rar-compressed";
|
||||
case ".7z": return "application/x-7z-compressed";
|
||||
case ".gz": return "application/gzip";
|
||||
case ".tar": return "application/x-tar";
|
||||
|
||||
// Email
|
||||
case ".eml": return "message/rfc822";
|
||||
case ".msg": return "application/vnd.ms-outlook";
|
||||
|
||||
// Audio / video — rare in legal mail but cheap to handle
|
||||
case ".mp3": return "audio/mpeg";
|
||||
case ".wav": return "audio/wav";
|
||||
case ".mp4": return "video/mp4";
|
||||
|
||||
default: return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
private static T? SafeGet<T>(Func<T> getter) where T : class
|
||||
{
|
||||
try { return getter(); } catch { return null; }
|
||||
|
||||
Reference in New Issue
Block a user