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)
|
||||
=> 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)
|
||||
{
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -92,6 +92,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 +187,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,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>
|
||||
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
|
||||
/// Creates a brand new MailItem, populates it with case details, then
|
||||
|
||||
Reference in New Issue
Block a user