feat(attachments): "save attachments only" ribbon action
New workflow alongside "תייק ל-Klear": save just the attachments of a
mail to a Case in EspoCRM (no Email entity created), with an optional
DocumentFolder.
Core:
- AttachmentEntity, DocumentEntity, DocumentFolder DTOs
- IEspoCrmClient gets three new endpoints:
UploadAttachmentAsync (POST /Attachment, data-URI base64, role=Attachment)
CreateDocumentAsync (POST /Document, fileId + parent + folderId)
ListDocumentFoldersAsync (GET /DocumentFolder, returns envelope)
- AttachmentSaveService orchestrates per-attachment upload + Document
create, short-circuits on EspoCrmAuthorizationException, aggregates
per-file Saved/Failed/AuthRequired outcomes into AttachmentSaveSummary
UI:
- SaveAttachmentsDialog (RTL, card layout matching FileToCaseDialog):
Case search + folder dropdown + per-attachment checkbox list
- SaveAttachmentsViewModel: debounced GlobalSearch filtered to Case,
optional folder selector (with a "(no folder)" sentinel row),
Attachments collection of AttachmentRowViewModel rows
- AttachmentRowViewModel: IsSelected checkbox + display name + size
computed from base64 length
Host:
- AddInHost.LaunchSaveAttachmentsAsync(MailItem) — extracts on the STA
via MailItemExtractor, fetches folders, shows the dialog (anchored to
Outlook via AttachOwnerToOutlook), runs AttachmentSaveService on OK,
reports a Hebrew MessageBox summary
- AttachmentSaveService field on AddInHost + dispose hook
Ribbon:
- New "שמור קבצים" button in the Marcus-Law group, between Klear and
פרטי תיק. Enabled only when exactly one MailItem is selected and it
has at least one attachment
- klear-attach.png paperclip icon (32x32, blue rounded square),
embedded as a resource and resolved by the existing OnGetImage
callback (new case in the switch)
Tests: 39 still passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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("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,97 @@
|
|||||||
|
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,
|
||||||
|
FilingTarget target,
|
||||||
|
string? folderId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attachments == null) throw new ArgumentNullException(nameof(attachments));
|
||||||
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
|
|
||||||
|
var summary = new AttachmentSaveSummary();
|
||||||
|
if (attachments.Count == 0) return summary;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
var uploaded = await _client.UploadAttachmentAsync(
|
||||||
|
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var doc = await _client.CreateDocumentAsync(
|
||||||
|
att.Name, uploaded.Id, target.ParentType, target.ParentId, folderId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.Information(
|
||||||
|
"Saved attachment {Name} → Document/{DocId} (parent={ParentType}/{ParentId}, folder={FolderId})",
|
||||||
|
att.Name, doc.Id, target.ParentType, target.ParentId, folderId ?? "(none)");
|
||||||
|
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.Saved,
|
||||||
|
DocumentId = doc.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,86 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||||
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
=> 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));
|
||||||
|
|
||||||
|
// EspoCRM accepts either raw base64 in `contents` or a data-URI with the
|
||||||
|
// mime prefix. The data-URI form is universally supported across versions.
|
||||||
|
var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||||
|
var dataUri = "data:" + mime + ";base64," + base64Content;
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = fileName,
|
||||||
|
["type"] = mime,
|
||||||
|
["contents"] = dataUri,
|
||||||
|
["role"] = "Attachment",
|
||||||
|
["relatedType"] = "Document",
|
||||||
|
["field"] = "file"
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureSuccessAsync(response).ConfigureAwait(false);
|
||||||
|
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));
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = name,
|
||||||
|
["fileId"] = attachmentFileId,
|
||||||
|
["status"] = "Active"
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureSuccessAsync(response).ConfigureAwait(false);
|
||||||
|
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<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await GetJsonAsync<EspoListResponse<DocumentFolder>>(
|
||||||
|
"DocumentFolder?orderBy=name&order=asc&maxSize=200",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
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)
|
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));
|
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 to EspoCRM and creates a Document linked
|
||||||
|
/// to the given target (typically a Case). Folder is optional —
|
||||||
|
/// pass null to leave the document at the root of the Documents
|
||||||
|
/// module.
|
||||||
|
/// </summary>
|
||||||
|
Task<AttachmentSaveSummary> SaveAsync(
|
||||||
|
IReadOnlyList<EspoAttachment> attachments,
|
||||||
|
FilingTarget target,
|
||||||
|
string? folderId,
|
||||||
|
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? DocumentId { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AttachmentSaveOutcome
|
||||||
|
{
|
||||||
|
Saved,
|
||||||
|
Failed,
|
||||||
|
AuthRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,9 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
|
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
|
||||||
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
|
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
|
||||||
Task<UserInfo> TestConnectionAsync(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<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 Folders}"
|
||||||
|
SelectedItem="{Binding SelectedFolder}"
|
||||||
|
DisplayMemberPath="Name"
|
||||||
|
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,185 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
public sealed partial class SaveAttachmentsViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IEspoCrmClient _client;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string searchText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private CaseEntity? selectedCase;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private DocumentFolder? selectedFolder;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? statusMessage;
|
||||||
|
|
||||||
|
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
|
||||||
|
|
||||||
|
public ObservableCollection<DocumentFolder> Folders { get; } = new ObservableCollection<DocumentFolder>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public FilingTarget? Target
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return null;
|
||||||
|
return new FilingTarget("Case", SelectedCase.Id, SelectedCase.Name ?? SelectedCase.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? FolderId =>
|
||||||
|
SelectedFolder == null || string.IsNullOrWhiteSpace(SelectedFolder.Id)
|
||||||
|
? null
|
||||||
|
: SelectedFolder.Id;
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Sentinel "no folder" row at the top — Id stays null so the
|
||||||
|
// service skips emitting folderId in the payload.
|
||||||
|
Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" });
|
||||||
|
SelectedFolder = Folders[0];
|
||||||
|
|
||||||
|
foreach (var att in attachments)
|
||||||
|
{
|
||||||
|
Attachments.Add(new AttachmentRowViewModel(att));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadFoldersAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _client.ListDocumentFoldersAsync(ct).ConfigureAwait(true);
|
||||||
|
foreach (var folder in result.List)
|
||||||
|
{
|
||||||
|
Folders.Add(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "SaveAttachmentsViewModel.LoadFoldersAsync failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSearchTextChanged(string value)
|
||||||
|
{
|
||||||
|
_searchCts?.Cancel();
|
||||||
|
_searchCts = new CancellationTokenSource();
|
||||||
|
_ = SearchAsync(value, _searchCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedCaseChanged(CaseEntity? value)
|
||||||
|
{
|
||||||
|
SubmitCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 |
@@ -270,6 +270,9 @@
|
|||||||
<EmbeddedResource Include="Images\klear-compose.png">
|
<EmbeddedResource Include="Images\klear-compose.png">
|
||||||
<LogicalName>OutlookAddin.Images.klear-compose.png</LogicalName>
|
<LogicalName>OutlookAddin.Images.klear-compose.png</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="Images\klear-attach.png">
|
||||||
|
<LogicalName>OutlookAddin.Images.klear-attach.png</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
<Compile Include="Services\AddInHost.cs">
|
<Compile Include="Services\AddInHost.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
|||||||
@@ -92,6 +92,43 @@ namespace OutlookAddin.Ribbon
|
|||||||
Globals.ThisAddIn.AddInHost.ShowSidebar();
|
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)
|
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
|
||||||
{
|
{
|
||||||
// Show only on outgoing (compose) mail inspectors.
|
// Show only on outgoing (compose) mail inspectors.
|
||||||
@@ -150,6 +187,7 @@ namespace OutlookAddin.Ribbon
|
|||||||
switch (control.Id)
|
switch (control.Id)
|
||||||
{
|
{
|
||||||
case "FileToEspoCrm": fileName = "klear-file.png"; break;
|
case "FileToEspoCrm": fileName = "klear-file.png"; break;
|
||||||
|
case "SaveAttachments": fileName = "klear-attach.png"; break;
|
||||||
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
|
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
|
||||||
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
|
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
|
||||||
case "ComposeFromCase": fileName = "klear-compose.png"; break;
|
case "ComposeFromCase": fileName = "klear-compose.png"; break;
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
getImage="OnGetImage"
|
getImage="OnGetImage"
|
||||||
onAction="OnFileToEspoCrm"
|
onAction="OnFileToEspoCrm"
|
||||||
getEnabled="OnGetFileToEspoCrmEnabled" />
|
getEnabled="OnGetFileToEspoCrmEnabled" />
|
||||||
|
<button id="SaveAttachments"
|
||||||
|
label="שמור קבצים"
|
||||||
|
screentip="שמור את הקבצים המצורפים בלבד"
|
||||||
|
supertip="מעלה את הקבצים המצורפים של המייל הנבחר ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
|
||||||
|
size="large"
|
||||||
|
getImage="OnGetImage"
|
||||||
|
onAction="OnSaveAttachments"
|
||||||
|
getEnabled="OnGetSaveAttachmentsEnabled" />
|
||||||
<button id="ShowSidebar"
|
<button id="ShowSidebar"
|
||||||
label="פרטי תיק"
|
label="פרטי תיק"
|
||||||
screentip="הצג את סרגל פרטי התיק"
|
screentip="הצג את סרגל פרטי התיק"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace OutlookAddin.Services
|
|||||||
public IEspoCrmClient? CrmClient { get; private set; }
|
public IEspoCrmClient? CrmClient { get; private set; }
|
||||||
public IFilingService? FilingService { get; private set; }
|
public IFilingService? FilingService { get; private set; }
|
||||||
public IMatchingService? MatchingService { get; private set; }
|
public IMatchingService? MatchingService { get; private set; }
|
||||||
|
public IAttachmentSaveService? AttachmentSaveService { get; private set; }
|
||||||
public ComposeService? ComposeService { get; private set; }
|
public ComposeService? ComposeService { get; private set; }
|
||||||
|
|
||||||
private readonly Outlook.Application _outlookApp;
|
private readonly Outlook.Application _outlookApp;
|
||||||
@@ -353,6 +354,7 @@ namespace OutlookAddin.Services
|
|||||||
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
||||||
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
||||||
ComposeService = new ComposeService(CrmClient, Logger);
|
ComposeService = new ComposeService(CrmClient, Logger);
|
||||||
|
AttachmentSaveService = new AttachmentSaveService(CrmClient, Logger);
|
||||||
_folderResolver ??= new FolderResolver(_outlookApp, Logger);
|
_folderResolver ??= new FolderResolver(_outlookApp, Logger);
|
||||||
_folderWatcher = new FolderWatcher(
|
_folderWatcher = new FolderWatcher(
|
||||||
_outlookApp,
|
_outlookApp,
|
||||||
@@ -387,6 +389,7 @@ namespace OutlookAddin.Services
|
|||||||
FilingService = null;
|
FilingService = null;
|
||||||
MatchingService = null;
|
MatchingService = null;
|
||||||
ComposeService = null;
|
ComposeService = null;
|
||||||
|
AttachmentSaveService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -428,6 +431,109 @@ 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);
|
||||||
|
// Best-effort folder list — failures already logged inside the VM.
|
||||||
|
_ = vm.LoadFoldersAsync();
|
||||||
|
|
||||||
|
var dialog = new SaveAttachmentsDialog(vm);
|
||||||
|
AttachOwnerToOutlook(dialog);
|
||||||
|
var ok = dialog.ShowDialog();
|
||||||
|
if (ok != true) return;
|
||||||
|
|
||||||
|
var target = vm.Target;
|
||||||
|
var selected = vm.SelectedAttachments;
|
||||||
|
if (target == null || selected.Count == 0) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Logger.Information(
|
||||||
|
"Saving {Count} attachment(s) to {Type}/{Id} ({Name}), folder={FolderId}",
|
||||||
|
selected.Count, target.ParentType, target.ParentId, target.ParentName, vm.FolderId ?? "(none)");
|
||||||
|
|
||||||
|
var summary = await AttachmentSaveService!.SaveAsync(selected, target, vm.FolderId).ConfigureAwait(true);
|
||||||
|
ShowAttachmentSummary(summary, target);
|
||||||
|
}
|
||||||
|
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, FilingTarget target)
|
||||||
|
{
|
||||||
|
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} קבצים אל \"{target.ParentName}\"");
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
|
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
|
||||||
/// Creates a brand new MailItem, populates it with case details, then
|
/// Creates a brand new MailItem, populates it with case details, then
|
||||||
|
|||||||
Reference in New Issue
Block a user