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:
PointStar
2026-05-12 20:12:28 +03:00
parent 0fea393b81
commit b1f8e3e50b
16 changed files with 958 additions and 0 deletions
@@ -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

+3
View File
@@ -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>
+38
View File
@@ -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="הצג את סרגל פרטי התיק"
+106
View File
@@ -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