Compare commits

...

11 Commits

Author SHA1 Message Date
PointStar d7a53da59f fix(sidebar): resolve MatchingService live + settings "סגור" label
Sidebar lookups threw ObjectDisposedException on the EspoCrmClient's
HttpClient after any settings save. TryInitializeCrm rebuilds the CRM
stack (new HttpClient + MatchingService) and disposes the old HttpClient,
but the long-lived sidebar ReadingPaneViewModel had captured the old
MatchingService and kept calling into the disposed client.

ReadingPaneViewModel now resolves the MatchingService live on each lookup
via a Func<IMatchingService?> provider (() => host.MatchingService), so
it always uses the current stack and degrades gracefully (shows "not
configured") when none exists. SidebarController no longer captures a
snapshot.

Also: rename the Settings dialog's bottom button from "ביטול" to "סגור".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:10:47 +03:00
PointStar 6a51a58224 fix(save-attachments): marshal folder-tree mutations to the Dispatcher
v1.2.15's tree picker built the whole tree inside an async method and
mutated the TreeView-bound ObservableCollections after an await. In this
VSTO host the WPF SynchronizationContext is not installed, so
ConfigureAwait(true) resumed on a thread-pool thread and FolderTree.Add
threw "changes to its SourceCollection from a thread different from the
Dispatcher thread" — the exception was swallowed and the tree showed
empty (no folders selectable at all).

Capture the UI Dispatcher in the view-model and marshal every bound
collection mutation (FolderTree + each node's Children) through it, so
the tree populates regardless of which thread the network await resumes
on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:58:53 +03:00
PointStar 9b451c9cbe feat(save-attachments): nested-folder tree picker + top-most message boxes
1.2.15

Folder picker: replace the flat subfolder dropdown with a lazy-loading
TreeView so the user can drill into nested subfolders to any depth.
New FolderNodeViewModel lists each folder's children on first expand;
the case-root node is selected/expanded by default and seeded with the
default subfolders (kept even if the share listing fails). ChosenSubfolderName
now returns a multi-segment relative path, appended to the case folder
as before.

Message boxes: route all of AddInHost's MessageBox calls through a new
ShowMessage helper that anchors a transient top-most owner window to the
foreground Outlook window. A bare MessageBox has no owner once our dialog
closes, so it sank behind Outlook and the user never saw the success/error
result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:35:43 +03:00
PointStar 35ce8facff fix(attachments): bind upload to Note.attachments to satisfy EspoCRM
The EspoCRM tenant now rejects role=Attachment uploads that carry
neither `field` nor `parentType` (400 "No `field` and `parentType`."),
which broke the entire save-attachments flow on v1.2.13 — every file,
including PDFs, failed at Step 1.

Bind the upload to the documented Attachment-Multiple example
parentType=Note, field=attachments. Note.attachments is a built-in
field with no allowed-file-types restriction, so all types pass
(including images) — unlike Document.file, which rejected images with
403. No parentId is set; the blob stays standalone and is still just
pushed to the network share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:28:22 +03:00
PointStar c685f4f91d publish: 1.2.13 — preselect sender case, save images, save-from-open-mail
Three fixes to the save-attachments flow:

1. Preselect the identified client case. When the mail sender already
   maps to a CRM contact, the "שמור קבצים" dialog now seeds its case
   list from the sidebar match (cached lookup) instead of opening empty
   — single linked case auto-selects (and resolves the target folder);
   several show for the user to pick. Search still overrides.

2. Save image attachments. EspoCRM's Document.file field is configured
   to accept document types only, so JPEG/PNG uploads were rejected with
   403 "Not allowed file type". The save-attachments flow never creates a
   Document — it only pushes the blob to the network share — so we now
   POST the Attachment unbound (no relatedType=Document/field=file),
   skipping the per-field accept check and letting every type through.

3. Save from an open message. Adds a "שמור קבצים" button to the read-mail
   inspector ribbon (Microsoft.Outlook.Mail.Read) so the user no longer
   has to return to the message list to file attachments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:49:16 +03:00
PointStar 5eae3da213 publish: 1.2.12 — harden VSTO rename + reissue broken v1.2.11 release
v1.2.11 shipped a broken publish.tar.gz: the Rename-Item that turns
`MarcusLaw.OutlookAddin.vsto` into the canonical `OutlookAddin.vsto`
failed silently (script ran with $ErrorActionPreference = 'Continue'),
so the docker image had no OutlookAddin.vsto at the expected path and
the CDN 404'd on the auto-update URL. Gitea Generic packages are
immutable, so 1.2.11 cannot be republished.

Restrict the .vsto lookup to publish/ root (Get-ChildItem -File, no
-Recurse) so the inner versioned manifest under Application Files is
never picked, switch the rename to -ErrorAction Stop, and assert that
publish/OutlookAddin.vsto exists before continuing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:31:14 +03:00
PointStar 8264c11b57 publish: 1.2.11 — compose-from-case email + picker columns fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:56:32 +03:00
PointStar 4556d2c1b3 fix(compose-from-case): use any contact with email + show number/status
Walk Case.contactsIds for the first contact with a non-empty emailAddress
(falling back to the Account's emailAddress) instead of blindly using
contactsIds[0] and giving up if it has no email — Outlook was opening
the new mail with no To recipient when the first linked contact was
e.g. opposing counsel or a witness without a stored address.

Also hydrate the "כתוב מתיק" picker rows with number/status/court#
through GetCaseAsync (same pattern as SaveAttachmentsViewModel) and
switch the dialog to the DataGrid layout so it matches the file-
attachments picker the user expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:56:18 +03:00
PointStar a8e947c825 docs: reflect v1.2.9 auto-update settings + v1.2.7 button behavior
Updates after the day-long updater overhaul:

- The csproj no longer has <UpdateInterval>7</UpdateInterval>; v1.2.9
  switched to <UpdatePeriodically>false</UpdatePeriodically>, so the
  VSTO runtime checks the .vsto manifest on every Outlook startup.
  Both PROJECT-BRIEFING.md ("How releases work") and
  AUTO-UPDATE-SETUP.md (section 5) now describe the on-startup model
  instead of the weekly-poll model that was never operationally true.
- AUTO-UPDATE-SETUP.md also gets two new notes that came out of today:
  what the "בדוק עדכונים" button can and can't do (informational only,
  pointing at the in-process-update impossibility memo), and the
  one-time uninstall+reinstall required when a developer's machine has
  a dev-cert install that can't auto-update across to the prod cert.

No code change; no tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:14:00 +03:00
PointStar 32f6facb67 publish: 1.2.10 — verify on-every-startup auto-update from v1.2.9
No code change. After v1.2.9 set UpdatePeriodically=false, the VSTO
runtime should hit the .vsto manifest on every Outlook launch. From a
clean v1.2.9 install: close Outlook, reopen, About tab shows 1.2.10
with no clicks anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:53:54 +03:00
PointStar 2228353c48 fix(updater): check for updates on every Outlook startup, not every 7 days
Why v1.2.7→v1.2.8 didn't auto-install on Outlook restart: the manifest
was published with <UpdateInterval>7</UpdateInterval> + units=days, which
tells the VSTO runtime "don't even check the manifest URL more often
than once a week." MinimumRequiredVersion (set by CI to the same tag
version) would have forced the install — but only AFTER the check runs,
and the check was throttled to weekly.

Replace UpdateInterval/UpdateIntervalUnits with UpdatePeriodically=false.
This omits the <expiration> element from the deployment manifest, which
ClickOnce/VSTO interprets as "check before every host start." The extra
HTTP GET on the .vsto manifest at Outlook launch is ~6KB, ~100ms — a
non-issue for startup time, vs. the previous behavior where new
releases sat unused on platform.dev for up to a week per user.

From v1.2.9 onward, tag → CI publishes → next Outlook launch on each
lawyer's PC installs it. This is what we wanted the auto-update chain
to look like all along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:50:31 +03:00
19 changed files with 683 additions and 158 deletions
+13 -7
View File
@@ -258,15 +258,21 @@ jobs:
& msbuild @msbuildArgs
if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" }
# Sanity check: did the VSTO publish target produce a .vsto file?
$vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" }
# Sanity check + rename to canonical OutlookAddin.vsto so the URL
# matches PROVIDER_URL (hardcoded to .../OutlookAddin.vsto). Restrict
# to publish/ root -- there's a second .vsto inside Application Files
# that must keep its versioned name. Verify the renamed file exists
# before continuing; v1.2.11 shipped broken because a silent failure
# here left no OutlookAddin.vsto in the tar and 404'd the CDN.
$vsto = Get-ChildItem -File -Filter '*.vsto' -Path publish | Select-Object -First 1
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found at publish/ root" }
Write-Host " produced $($vsto.FullName)"
# Rename to canonical OutlookAddin.vsto so the ClickOnce manifest
# URL matches PROVIDER_URL (which is hardcoded to .../OutlookAddin.vsto).
if ($vsto.Name -ne 'OutlookAddin.vsto') {
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto'
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto' -ErrorAction Stop
Write-Host " renamed -> OutlookAddin.vsto"
}
if (-not (Test-Path 'publish/OutlookAddin.vsto')) {
throw "post-rename: publish/OutlookAddin.vsto does not exist"
}
Write-Host "=== STEP F: stage protocol handler ==="
+5 -10
View File
@@ -315,16 +315,11 @@ git push origin v1.0.1
ה-CI ירוץ אוטומטית כמו בשחרור הראשון.
**אצל הלקוחות:**
- ClickOnce בודק עדכון אוטומטית **כל 7 ימים** (הגדרה ב-csproj: `UpdateInterval=7`).
- כדי **להאיץ** עדכון אצל לקוח מסויים:
1. סגור את Outlook לגמרי.
2. פתח את Outlook שוב.
3. אם עברו לפחות 7 ימים מבדיקת ה-update הקודמת — יבדוק עכשיו ויעדכן.
4. אם לא — אפשר לנקות את ה-ClickOnce cache:
```powershell
rundll32.exe dfshim.dll,CleanOnlineAppCache
```
ואז לפתוח שוב את `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`.
- ה־VSTO runtime בודק את ה-`.vsto` manifest **בכל פתיחה של Outlook** (משוחזר מ-`<UpdatePeriodically>false</UpdatePeriodically>` ב-csproj החל מ-v1.2.9; קודם זה היה פעם ב-7 ימים). הבדיקה היא GET של ~6KB, ~100ms — בלתי מורגש בזמן הטעינה.
- ה-CI מציב `/p:MinimumRequiredVersion=<tag>` בכל build, אז מיד כשהבדיקה מזהה גרסה חדשה — ההתקנה נכפית בלי דיאלוג.
- **תרגום מעשי**: 30 שניות אחרי `git push --tags` ה-CI מסיים → תוך 2-3 דקות החבילה נדחפת ל-CDN → בכניסה הבאה של כל אחד מהלקוחות ל-Outlook הוא רץ על הגרסה החדשה. **בלי שום פעולה ידנית מצידם**.
- **הכפתור "בדוק עדכונים" בהגדרות** רק מודיע ("זמינה גרסה X — סגור ופתח Outlook"). הוא לא יכול להתקין מתוך תהליך Outlook רץ — `VSTOInstaller.exe` לא יכול לדרוס DLLs נעולים, וה-ApplicationDeployment API נכשל ב-`TrustNotGrantedException` בהקשר VSTO. ההתקנה תמיד קורית רק דרך ה-runtime בעלייה של Outlook. [פירוט בזיכרון: feedback_vsto_updater_api.md]
- **תקלה נדירה — לקוח שהותקן ידנית עם dev cert** (`HP-OFFICE1\<user>`, publicKeyToken `41d8d795775ea8cb`) לא יכול לעבור auto-update לגרסת prod cert (`Marcus-Law OutlookAddin`, publicKeyToken `c68d2b4c25051c5b`) — ClickOnce מתייחס אליהן כשתי אפליקציות שונות. **פעם אחת**: Apps & Features → uninstall של MarcusLaw.OutlookAddin → התקנה חדשה מ-`https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`. אחרי זה auto-update עובד.
---
+1 -1
View File
@@ -284,7 +284,7 @@ If the session is on the Linux dev server (`/home/chaim/espocrm-extensions/Outlo
- Uploads `publish.tar.gz` to Gitea generic registry (`outlook-addin-publish/<version>/publish.tar.gz`)
- Linux job downloads the tar.gz, builds a Docker image `outlook-addin-cdn:latest`, pushes to Gitea registry, calls Coolify to redeploy the CDN container
- Posts to Mattermost channel `git-verelasim` with the install URL on success / a failure link on failure
5. Lawyers' installed Add-in checks the deployment manifest at the install URL every 7 days (configured in csproj `<UpdateInterval>7</UpdateInterval>`) or whenever they click "בדוק עדכונים" in Settings → אודות.
5. Lawyers' installed Add-in checks the deployment manifest at the install URL **on every Outlook startup** (csproj has `<UpdatePeriodically>false</UpdatePeriodically>` since v1.2.9 — earlier versions waited up to 7 days). The check is a single ~6 KB HTTP GET, ~100 ms. Combined with the CI setting `/p:MinimumRequiredVersion=<tag>` per build, this means every tagged release lands on every lawyer's PC the next time they open Outlook, silently and without prompts.
**Semver policy:** PATCH for bug fixes (unilateral), MINOR for visible features (unilateral when warranted), MAJOR only with Chaim's explicit approval.
@@ -213,13 +213,29 @@ namespace MarcusLaw.OutlookAddin.Core.Services
// is sent in the "file" field as a data-URI. The previous extra
// "contents" alias was an undocumented legacy that strict tenants
// reject with 400.
//
// We bind the upload to Note.attachments (parentType=Note,
// field=attachments). EspoCRM's attachment access checker now
// REJECTS a role=Attachment upload that carries neither `field`
// nor `parentType` ("No `field` and `parentType`." → 400) — the
// earlier "unbound blob" shape no longer passes. Note.attachments
// is the documented Attachment-Multiple example and a built-in
// field that carries NO "allowed file types" restriction, so every
// file type (PDF, JPEG, PNG, …) passes — unlike Document.file,
// which Marcus-Law limits to PDF/Word and which rejected images
// with 403 "Not allowed file type".
//
// We never set parentId / create a Note: the attachment stays a
// standalone blob whose id we immediately push to the network
// share. The (parentType, field) pair only satisfies the access
// checker and selects an unrestricted file-type policy.
var payload = new Dictionary<string, object?>
{
["name"] = fileName,
["type"] = mime,
["role"] = "Attachment",
["relatedType"] = "Document",
["field"] = "file",
["parentType"] = "Note",
["field"] = "attachments",
["file"] = dataUri
};
@@ -1,9 +1,10 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="כתוב מייל מתיק"
Height="480" Width="600"
MinHeight="320" MinWidth="500"
Height="520" Width="680"
MinHeight="360" MinWidth="540"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
@@ -11,6 +12,7 @@
Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -134,24 +136,30 @@
</Border>
<Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
BorderThickness="0"
Background="Transparent"
ItemContainerStyle="{StaticResource ResultItem}"
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>
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#E2E8F0"
RowBackground="White"
AlternatingRowBackground="#F8FAFC"
BorderThickness="0"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserResizeRows="False"
CanUserReorderColumns="False"
SelectionMode="Single"
SelectionUnit="FullRow"
IsReadOnly="True"
RowHeight="30">
<DataGrid.Columns>
<DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
<DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
<DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
</DataGrid.Columns>
</DataGrid>
</Border>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="שמור קבצים מצורפים ל-Klear"
Height="560" Width="620"
MinHeight="420" MinWidth="500"
Height="640" Width="620"
MinHeight="480" MinWidth="500"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
@@ -144,24 +144,38 @@
<!-- 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" />
<TextBlock DockPanel.Dock="Right" Text="טוען…" VerticalAlignment="Center"
Margin="8,0,0,0" Foreground="{StaticResource Muted}" FontSize="12"
Visibility="{Binding IsLoadingSubfolders, Converter={StaticResource BoolToVis}}" />
<ComboBox ItemsSource="{Binding Subfolders}"
SelectedItem="{Binding SelectedSubfolder}"
Padding="6,4">
<ComboBox.Style>
<Style TargetType="ComboBox">
<Style.Triggers>
<DataTrigger Binding="{Binding IsLoadingSubfolders}" Value="True">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
<DockPanel DockPanel.Dock="Top" LastChildFill="True" Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="תיקיית יעד:" VerticalAlignment="Center"
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
<TextBlock DockPanel.Dock="Right" Text="טוען…" VerticalAlignment="Center"
Margin="8,0,0,0" Foreground="{StaticResource Muted}" FontSize="12"
Visibility="{Binding IsLoadingSubfolders, Converter={StaticResource BoolToVis}}" />
<TextBlock Text="בחר/י תיקייה או תת-תיקייה לשמירה (ניתן להרחיב רמות)"
VerticalAlignment="Center" Foreground="{StaticResource Muted}" FontSize="11"
TextTrimming="CharacterEllipsis" />
</DockPanel>
<Border Style="{StaticResource Card}" MaxHeight="150">
<TreeView x:Name="FolderTreeView"
ItemsSource="{Binding FolderTree}"
BorderThickness="0"
Background="Transparent"
SelectedItemChanged="FolderTreeView_SelectedItemChanged">
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1">
<TextBlock Text="📁" Margin="0,0,4,0" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="{Binding Name}" VerticalAlignment="Center" FontSize="13" />
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</Border>
</DockPanel>
<!-- Attachments header -->
@@ -32,5 +32,15 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs
DialogResult = _viewModel?.DialogResult;
Close();
}
// WPF's TreeView.SelectedItem is read-only, so we mirror the selection
// into the view-model here (the chosen node drives the save target).
private void FolderTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (_viewModel != null)
{
_viewModel.SelectedFolder = e.NewValue as FolderNodeViewModel;
}
}
}
}
@@ -152,7 +152,7 @@
MinWidth="100" />
<Button Command="{Binding CancelCommand}"
IsCancel="True"
Content="ביטול"
Content="סגור"
Padding="20,5"
MinWidth="100" />
</StackPanel>
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested();
Cases.Clear();
int kept = 0;
var caseHits = new List<EspoEntityRef>();
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++;
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
caseHits.Add(hit);
}
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)"
: $"נמצאו {kept} תיקים";
if (caseHits.Count == 0)
{
StatusMessage = "אין תוצאות מסוג תיק (Case)";
return;
}
// GlobalSearch only returns id+name. Hydrate each hit with
// number+status (and the Hebrew case name when present) so the
// picker shows the same columns the file-attachments picker
// does.
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
foreach (var hit in caseHits)
{
detailTasks.Add(LoadCaseRowAsync(hit, ct));
}
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
foreach (var row in details)
{
if (row != null) Cases.Add(row);
}
StatusMessage = $"נמצאו {Cases.Count} תיקים";
}
catch (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException)
@@ -101,6 +118,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
{
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
if (string.IsNullOrEmpty(hit.Id)) return row;
try
{
var detail = await _client.GetCaseAsync(
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
row.Number = detail.Number;
row.Status = detail.Status;
row.CourtCaseNumber = detail.CourtCaseNumber;
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.Warning(ex, "ComposeFromCase: hydrating case {CaseId} failed", hit.Id);
}
return row;
}
[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit()
{
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
/// <summary>
/// One node in the network-storage folder tree shown by the
/// save-attachments dialog. Children are listed lazily the first time the
/// node is expanded, so the user can drill into nested subfolders to any
/// depth without us listing the whole tree up front.
/// </summary>
public sealed partial class FolderNodeViewModel : ObservableObject
{
private readonly IEspoCrmClient? _client;
private readonly ILogger? _logger;
private readonly Dispatcher? _dispatcher;
private bool _childrenLoaded;
private bool _loadingStarted;
/// <summary>Label shown in the tree.</summary>
public string Name { get; }
/// <summary>
/// Path relative to the share root (e.g. "56-ליאור זייני/מסמכים").
/// Used to list this folder's children. Empty for placeholders.
/// </summary>
public string FullPath { get; }
/// <summary>
/// Path relative to the case-root folder (e.g. "מסמכים/רפואי"), or
/// null for the case-root node itself. This is what the save target
/// appends to the resolved case folder.
/// </summary>
public string? RelativePath { get; }
/// <summary>True for the transient "loading…" child placeholder.</summary>
public bool IsPlaceholder { get; }
public bool IsRoot => !IsPlaceholder && RelativePath == null;
public ObservableCollection<FolderNodeViewModel> Children { get; } =
new ObservableCollection<FolderNodeViewModel>();
[ObservableProperty]
private bool isExpanded;
[ObservableProperty]
private bool isSelected;
[ObservableProperty]
private bool isLoading;
/// <summary>Real folder node.</summary>
public FolderNodeViewModel(
IEspoCrmClient client,
ILogger logger,
Dispatcher dispatcher,
string name,
string fullPath,
string? relativePath)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
Name = name;
FullPath = fullPath;
RelativePath = relativePath;
// A placeholder child makes the expand chevron appear before we've
// listed; expanding swaps it for the real children (or, if there
// are none, the node becomes a leaf).
Children.Add(new FolderNodeViewModel());
}
/// <summary>Placeholder ("loading…") node constructor.</summary>
private FolderNodeViewModel()
{
Name = "טוען…";
FullPath = string.Empty;
RelativePath = string.Empty;
IsPlaceholder = true;
_childrenLoaded = true;
}
partial void OnIsExpandedChanged(bool value)
{
if (value && !_childrenLoaded && !_loadingStarted)
{
_loadingStarted = true;
_ = LoadChildrenAsync();
}
}
/// <summary>
/// Lists this folder's subfolders and replaces the placeholder with
/// real child nodes. <paramref name="seedNames"/> (used for the root)
/// guarantees the default case subfolders show even if the share
/// listing lags or omits a not-yet-created folder.
/// </summary>
public async Task LoadChildrenAsync(IEnumerable<string>? seedNames = null)
{
if (_client == null) return;
IsLoading = true;
// Build into a local list and swap once at the end. The seeds are
// added first so that even if the share listing fails the default
// subfolders still show; on failure for a non-seeded node the list
// is empty and the node reads as a leaf rather than "loading…".
var seen = new HashSet<string>(StringComparer.Ordinal);
var loaded = new List<FolderNodeViewModel>();
if (seedNames != null)
{
foreach (var n in seedNames)
{
if (seen.Add(n)) loaded.Add(MakeChild(n));
}
}
try
{
var items = await _client.ListNetworkStorageFolderAsync(FullPath).ConfigureAwait(true);
foreach (var item in items)
{
if (!item.IsFolder) continue;
if (!seen.Add(item.Name)) continue;
loaded.Add(MakeChild(item.Name));
}
}
catch (Exception ex)
{
_logger?.Warning(ex, "Listing subfolders under '{Path}' failed", FullPath);
}
finally
{
// Marshal onto the Dispatcher: once this node's TreeViewItem
// exists, Children is bound and must only be mutated on the UI
// thread (the network await above can resume on a thread-pool
// thread in this VSTO host).
OnUi(() =>
{
Children.Clear();
foreach (var child in loaded) Children.Add(child);
});
_childrenLoaded = true;
IsLoading = false;
}
}
private FolderNodeViewModel MakeChild(string name)
{
var fullPath = string.IsNullOrEmpty(FullPath) ? name : FullPath + "/" + name;
var relativePath = IsRoot ? name : RelativePath + "/" + name;
return new FolderNodeViewModel(_client!, _logger!, _dispatcher!, name, fullPath, relativePath);
}
private void OnUi(Action action)
{
var d = _dispatcher;
if (d == null || d.CheckAccess()) action();
else d.Invoke(action);
}
}
}
@@ -27,7 +27,12 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public sealed partial class ReadingPaneViewModel : ObservableObject
{
private readonly IMatchingService _matchingService;
// Resolved live on each lookup rather than captured once: the host
// rebuilds the CRM stack (new HttpClient + MatchingService) whenever
// settings are saved and disposes the old HttpClient. A captured
// snapshot would then call into a disposed HttpClient and throw
// ObjectDisposedException on every sidebar lookup.
private readonly Func<IMatchingService?> _matchingServiceProvider;
private readonly ILogger _logger;
private CancellationTokenSource? _activeLookup;
// Stashes the ambiguous MatchResult while the user is drilling into
@@ -94,9 +99,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary>
public event EventHandler<string>? OpenInBrowserRequested;
public ReadingPaneViewModel(IMatchingService matchingService, ILogger logger)
public ReadingPaneViewModel(Func<IMatchingService?> matchingServiceProvider, ILogger logger)
{
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_matchingServiceProvider = matchingServiceProvider ?? throw new ArgumentNullException(nameof(matchingServiceProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -162,12 +167,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
_ambiguousReturnTarget = null;
State = ReadingPaneState.Loading;
var svc = _matchingServiceProvider();
if (svc == null)
{
ErrorMessage = "התוסף עדיין לא מוגדר. עדכן בהגדרות.";
State = ReadingPaneState.Error;
return;
}
var cts = new CancellationTokenSource();
_activeLookup = cts;
try
{
var result = await _matchingService.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true);
var result = await svc.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true);
if (cts.IsCancellationRequested) return;
if (result == null)
@@ -296,6 +309,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return;
var svc = _matchingServiceProvider();
if (svc == null)
{
ErrorMessage = "התוסף עדיין לא מוגדר. עדכן בהגדרות.";
State = ReadingPaneState.Error;
return;
}
CancelInFlight();
var cts = new CancellationTokenSource();
_activeLookup = cts;
@@ -310,7 +331,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
try
{
var result = await _matchingService.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true);
var result = await svc.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true);
if (cts.IsCancellationRequested) return;
if (result == null || result.Contact == null)
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models;
@@ -31,6 +32,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
private readonly Dispatcher _dispatcher;
private CancellationTokenSource? _searchCts;
[ObservableProperty]
@@ -40,7 +42,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private CaseEntity? selectedCase;
[ObservableProperty]
private string? selectedSubfolder;
private FolderNodeViewModel? selectedFolder;
[ObservableProperty]
private string? statusMessage;
@@ -58,7 +60,12 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
/// <summary>
/// Folder tree for the selected case. Holds a single root node (the
/// case folder); its children are the subfolders, loaded lazily so the
/// user can drill into any nesting depth.
/// </summary>
public ObservableCollection<FolderNodeViewModel> FolderTree { get; } = new ObservableCollection<FolderNodeViewModel>();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
@@ -78,14 +85,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
/// <summary>
/// Subfolder string the user picked, or null to save to the case
/// folder root.
/// Folder the user picked, as a path relative to the case folder
/// (e.g. "מסמכים/רפואי"), or null to save to the case folder root.
/// Multi-segment paths are honoured by the save target, which appends
/// this to <see cref="ResolvedCaseFolderPath"/>.
/// </summary>
public string? ChosenSubfolderName =>
string.IsNullOrEmpty(SelectedSubfolder) ||
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
? null
: SelectedSubfolder;
public string? ChosenSubfolderName
{
get
{
var f = SelectedFolder;
if (f == null || f.IsPlaceholder || f.IsRoot) return null;
return string.IsNullOrEmpty(f.RelativePath) ? null : f.RelativePath;
}
}
public CaseEntity? PickedCase => SelectedCase;
@@ -98,8 +111,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Captured on the UI thread (the VM is constructed on the thread
// that shows the dialog). Used to marshal ObservableCollection
// mutations back onto the Dispatcher — in this VSTO host the WPF
// SynchronizationContext is not installed, so ConfigureAwait(true)
// resumes on a thread-pool thread and a bound-collection Add throws
// "changes ... from a thread different from the Dispatcher thread".
_dispatcher = Dispatcher.CurrentDispatcher;
ResetSubfolders();
ResetFolderTree();
foreach (var att in attachments)
{
@@ -107,12 +127,52 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
private void ResetSubfolders()
/// <summary>
/// Seeds the case list with the cases already resolved for the mail's
/// sender (from the sidebar match) so the user doesn't have to search
/// for a client we've already identified. When exactly one case is
/// known we auto-select it (which kicks the subfolder/folder-path
/// resolve); with several we just show them and let the user pick.
/// The user can still type in the search box to override — that
/// repopulates <see cref="Cases"/> from a fresh server search.
/// </summary>
public void PreselectCases(IReadOnlyList<CaseEntity> cases)
{
Subfolders.Clear();
Subfolders.Add(RootSentinel);
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
SelectedSubfolder = Subfolders[0];
if (cases == null || cases.Count == 0) return;
Cases.Clear();
foreach (var c in cases)
{
if (c == null || string.IsNullOrEmpty(c.Id)) continue;
Cases.Add(c);
}
if (Cases.Count == 0) return;
if (Cases.Count == 1)
{
SelectedCase = Cases[0];
StatusMessage = "התיק זוהה אוטומטית מהשולח";
}
else
{
StatusMessage = $"{Cases.Count} תיקים מקושרים לשולח — בחר/י תיק";
}
}
/// <summary>Runs <paramref name="action"/> on the UI/Dispatcher thread.</summary>
private void OnUi(Action action)
{
if (_dispatcher.CheckAccess()) action();
else _dispatcher.Invoke(action);
}
private void ResetFolderTree()
{
OnUi(() =>
{
FolderTree.Clear();
SelectedFolder = null;
});
}
partial void OnSearchTextChanged(string value)
@@ -137,7 +197,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync()
{
ResetSubfolders();
ResetFolderTree();
ResolvedCaseFolderPath = null;
var c = SelectedCase;
@@ -188,21 +248,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
if (string.IsNullOrEmpty(folderPath)) return;
// Build the tree rooted at the case folder. The root node is
// selected by default (→ save to case root, matching the previous
// default), expanded, and seeded with the default subfolders so
// they appear even before the share listing returns. Deeper levels
// load lazily as the user expands them.
var root = new FolderNodeViewModel(_client, _logger, _dispatcher, RootSentinel, folderPath, relativePath: null);
OnUi(() =>
{
FolderTree.Add(root);
SelectedFolder = root;
root.IsSelected = true;
});
try
{
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
foreach (var item in items)
{
if (!item.IsFolder) continue;
if (Subfolders.Contains(item.Name)) continue;
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
Subfolders.Add(item.Name);
}
await root.LoadChildrenAsync(DefaultCaseSubfolders).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
}
OnUi(() => root.IsExpanded = true);
}
/// <summary>
+5 -3
View File
@@ -36,11 +36,10 @@
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl>
<InstallUrl>https://gitea.dev.marcus-law.co.il/espocrm-extensions/OutlookAddin/raw/branch/main/Publish/</InstallUrl>
<TargetCulture>en</TargetCulture>
<ApplicationVersion>1.2.8.0</ApplicationVersion>
<ApplicationVersion>1.2.10.0</ApplicationVersion>
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
<UpdateEnabled>true</UpdateEnabled>
<UpdateInterval>7</UpdateInterval>
<UpdateIntervalUnits>days</UpdateIntervalUnits>
<UpdatePeriodically>false</UpdatePeriodically>
<ProductName>MarcusLaw.OutlookAddin</ProductName>
<PublisherName />
<SupportUrl />
@@ -258,6 +257,9 @@
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Ribbon\ReadMailRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.ReadMailRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Images\klear-file.png">
<LogicalName>OutlookAddin.Images.klear-file.png</LogicalName>
</EmbeddedResource>
+2 -2
View File
@@ -33,6 +33,6 @@ using System.Security;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.2.8.0")]
[assembly: AssemblyFileVersion("1.2.8.0")]
[assembly: AssemblyVersion("1.2.17.0")]
[assembly: AssemblyFileVersion("1.2.17.0")]
+49
View File
@@ -19,6 +19,7 @@ namespace OutlookAddin.Ribbon
{
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
private const string ReadMailResource = "OutlookAddin.Ribbon.ReadMailRibbon.xml";
// Every button whose `getEnabled` depends on the current Explorer
// selection must be re-evaluated on every SelectionChange — Office
@@ -40,6 +41,8 @@ namespace OutlookAddin.Ribbon
return LoadResource(ExplorerResource);
case "Microsoft.Outlook.Mail.Compose":
return LoadResource(InspectorResource);
case "Microsoft.Outlook.Mail.Read":
return LoadResource(ReadMailResource);
default:
return string.Empty;
}
@@ -77,6 +80,51 @@ namespace OutlookAddin.Ribbon
_inspectorRibbon = ribbon;
}
// The read-mail inspector ribbon loads fresh for each opened message,
// so its getEnabled callback re-runs per window — no cached IRibbonUI
// or invalidation is needed here.
public void OnReadMailRibbonLoad(IRibbonUI ribbon)
{
}
/// <summary>
/// Enabled when the message open in this read inspector has at least
/// one attachment. Mirrors <see cref="OnGetSaveAttachmentsEnabled"/>
/// but reads the item from the Inspector context rather than the
/// Explorer selection.
/// </summary>
public bool OnGetSaveAttachmentsInspectorEnabled(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem 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 OnSaveAttachmentsFromInspector(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem is Outlook.MailItem mail)
{
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
}
}
catch (Exception ex)
{
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachmentsFromInspector failed");
}
}
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
{
try
@@ -206,6 +254,7 @@ namespace OutlookAddin.Ribbon
{
case "FileToEspoCrm": fileName = "klear-file.png"; break;
case "SaveAttachments": fileName = "klear-attach.png"; break;
case "SaveAttachmentsRead": 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;
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="OnReadMailRibbonLoad">
<ribbon>
<tabs>
<tab idMso="TabReadMessage">
<group id="MarcusLawReadGroup" label="Klear">
<button id="SaveAttachmentsRead"
label="שמור קבצים"
screentip="שמור את הקבצים המצורפים בלבד"
supertip="מעלה את הקבצים המצורפים של המייל הזה ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
size="large"
getImage="OnGetImage"
onAction="OnSaveAttachmentsFromInspector"
getEnabled="OnGetSaveAttachmentsInspectorEnabled" />
</group>
</tab>
</tabs>
</ribbon>
</customUI>
+103 -29
View File
@@ -127,6 +127,62 @@ namespace OutlookAddin.Services
}
}
/// <summary>
/// Shows a message box that is guaranteed to render above Outlook and
/// any other window. A bare <see cref="MessageBox.Show(string)"/> has
/// no owner — once our WPF dialog closes there is no active window on
/// the add-in thread, so the box sinks behind Outlook and the user
/// never sees the success/error result. We anchor it to a transient,
/// invisible top-most owner window parented to the foreground Outlook
/// window so it always surfaces on top. Caption is always "Klear".
/// </summary>
private MessageBoxResult ShowMessage(
string text,
MessageBoxButton button = MessageBoxButton.OK,
MessageBoxImage icon = MessageBoxImage.None)
{
Window? owner = null;
try
{
// A 1×1 borderless window at screen centre: imperceptible, but
// gives the MessageBox a top-most owner to z-order above. The
// box centres on this point → effectively centred on screen.
owner = new Window
{
Width = 1,
Height = 1,
WindowStyle = WindowStyle.None,
ResizeMode = ResizeMode.NoResize,
ShowInTaskbar = false,
Topmost = true,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
};
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero)
{
hwnd = System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle;
}
if (hwnd != IntPtr.Zero)
{
new WindowInteropHelper(owner).Owner = hwnd;
}
owner.Show();
owner.Activate();
return MessageBox.Show(owner, text, "Klear", button, icon);
}
catch (Exception ex)
{
Logger.Warning(ex, "ShowMessage top-most owner failed; falling back to ownerless box");
return MessageBox.Show(text, "Klear", button, icon);
}
finally
{
try { owner?.Close(); } catch { /* ignore */ }
}
}
private static void EnableModernTls()
{
try
@@ -311,7 +367,6 @@ namespace OutlookAddin.Services
_sidebarController = new SidebarController(
taskPanes,
_outlookApp,
MatchingService!,
this,
System.Windows.Threading.Dispatcher.CurrentDispatcher,
Logger);
@@ -402,9 +457,8 @@ namespace OutlookAddin.Services
if (mail == null) return;
if (!IsConfigured)
{
MessageBox.Show(
ShowMessage(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -423,9 +477,8 @@ namespace OutlookAddin.Services
catch (Exception ex)
{
Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
MessageBox.Show(
ShowMessage(
"טעינת התיק נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -444,9 +497,8 @@ namespace OutlookAddin.Services
if (mail == null) return;
if (!IsConfigured)
{
var result = MessageBox.Show(
var result = ShowMessage(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Klear",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes) LaunchSettings();
@@ -463,9 +515,8 @@ namespace OutlookAddin.Services
catch (Exception ex)
{
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
MessageBox.Show(
ShowMessage(
"קריאת הקבצים המצורפים נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
return;
@@ -473,9 +524,8 @@ namespace OutlookAddin.Services
if (envelope.Attachments.Count == 0)
{
MessageBox.Show(
ShowMessage(
"אין במייל הזה קבצים מצורפים לשמירה.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -485,6 +535,13 @@ namespace OutlookAddin.Services
// Subfolders are populated lazily once the user picks a case;
// the default static set is already pre-loaded.
// If the sender already maps to a known client, seed the case
// list so the user sees the identified case pre-selected instead
// of having to search. Uses the same matching lookup the sidebar
// does (cached for 10 min), so this is normally instant. Any
// failure here is non-fatal — the dialog still opens empty.
await TryPreselectSenderCaseAsync(vm, envelope.From).ConfigureAwait(true);
var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog);
var ok = dialog.ShowDialog();
@@ -503,9 +560,8 @@ namespace OutlookAddin.Services
: SaveAttachmentsViewModel.ResolveCaseFolderPath(picked);
if (string.IsNullOrEmpty(caseFolder))
{
MessageBox.Show(
ShowMessage(
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
@@ -533,15 +589,38 @@ namespace OutlookAddin.Services
catch (Exception ex)
{
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
MessageBox.Show(
ShowMessage(
"שמירת הקבצים נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
/// <summary>
/// Looks up the mail sender against the CRM (reusing the sidebar's
/// cached match) and, when it resolves to one or more linked cases,
/// seeds the save-attachments dialog so the already-identified client
/// case shows up pre-selected. Best-effort: swallows any error.
/// </summary>
private async Task TryPreselectSenderCaseAsync(SaveAttachmentsViewModel vm, string? senderEmail)
{
if (MatchingService == null || string.IsNullOrWhiteSpace(senderEmail)) return;
try
{
var match = await MatchingService.LookupAsync(senderEmail!).ConfigureAwait(true);
// Ambiguous (same email on several contacts) → don't guess; let
// the user search. A clean match with linked cases → seed them.
if (match == null || match.IsAmbiguous) return;
if (match.RecentCases == null || match.RecentCases.Count == 0) return;
vm.PreselectCases(match.RecentCases);
}
catch (Exception ex)
{
Logger.Warning(ex, "Sender-case preselect for save-attachments failed (non-fatal)");
}
}
private void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
{
string message;
MessageBoxImage icon;
@@ -558,7 +637,7 @@ namespace OutlookAddin.Services
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
}
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon);
ShowMessage(message, MessageBoxButton.OK, icon);
}
/// <summary>
@@ -570,9 +649,8 @@ namespace OutlookAddin.Services
{
if (!IsConfigured)
{
MessageBox.Show(
ShowMessage(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -589,9 +667,8 @@ namespace OutlookAddin.Services
{
Logger.Error(ex, "LaunchComposeFromCaseByIdAsync failed for caseId={CaseId}", caseId);
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
MessageBox.Show(
ShowMessage(
"פתיחת המייל מתיק נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -654,9 +731,8 @@ namespace OutlookAddin.Services
{
if (!IsConfigured)
{
var result = MessageBox.Show(
var result = ShowMessage(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Klear",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes)
@@ -669,9 +745,8 @@ namespace OutlookAddin.Services
var mailItems = ReadCurrentSelection();
if (mailItems.Count == 0)
{
MessageBox.Show(
ShowMessage(
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -712,9 +787,8 @@ namespace OutlookAddin.Services
Logger.Warning(
"Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})",
vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType);
MessageBox.Show(
ShowMessage(
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
@@ -886,7 +960,7 @@ namespace OutlookAddin.Services
}
}
private static void ShowSummary(FilingBatchSummary summary, FilingTarget target)
private void ShowSummary(FilingBatchSummary summary, FilingTarget target)
{
string message;
MessageBoxImage icon;
@@ -907,7 +981,7 @@ namespace OutlookAddin.Services
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
}
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon);
ShowMessage(message, MessageBoxButton.OK, icon);
}
private static string? TryGetSenderName(Outlook.MailItem item)
+60 -19
View File
@@ -42,21 +42,7 @@ namespace OutlookAddin.Services
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
ContactEntity? primaryContact = null;
if (caseEntity.ContactsIds != null && caseEntity.ContactsIds.Count > 0)
{
try
{
primaryContact = await _client.GetContactAsync(
caseEntity.ContactsIds[0],
"id,name,emailAddress",
default).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load primary contact for case {CaseId}", caseId);
}
}
var recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
try
{
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress))
if (!string.IsNullOrWhiteSpace(recipientEmail))
{
var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!))
if (!existingTo.Contains(recipientEmail!))
{
mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress
: existingTo + "; " + primaryContact.EmailAddress;
? recipientEmail
: existingTo + "; " + recipientEmail;
}
}
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id));
}
/// <summary>
/// Find a usable recipient email for the case. EspoCRM's Case.contactsIds
/// is a many-to-many bag (primary + co-counsel + witnesses + …) and the
/// first entry isn't guaranteed to be the actual client — and even when
/// it is, the contact may have no emailAddress stored. So walk the list
/// and return the first non-empty address, then fall back to the
/// linked Account's email.
/// </summary>
private async Task<string?> ResolveRecipientEmailAsync(CaseEntity caseEntity, string caseId)
{
if (caseEntity.ContactsIds != null)
{
foreach (var contactId in caseEntity.ContactsIds)
{
if (string.IsNullOrWhiteSpace(contactId)) continue;
try
{
var contact = await _client.GetContactAsync(
contactId,
"id,name,emailAddress,emailAddressData",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(contact?.EmailAddress))
{
return contact!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load contact {ContactId} for case {CaseId}", contactId, caseId);
}
}
}
if (!string.IsNullOrWhiteSpace(caseEntity.AccountId))
{
try
{
var account = await _client.GetAccountAsync(
caseEntity.AccountId!,
"id,name,emailAddress",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(account?.EmailAddress))
{
return account!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load account {AccountId} for case {CaseId}", caseEntity.AccountId, caseId);
}
}
return null;
}
public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);
@@ -27,7 +27,6 @@ namespace OutlookAddin.Services
private readonly CustomTaskPaneCollection _taskPanes;
private readonly Outlook.Application _app;
private readonly IMatchingService _matchingService;
private readonly AddInHost _host;
private readonly Dispatcher _dispatcher;
private readonly ILogger _logger;
@@ -38,14 +37,12 @@ namespace OutlookAddin.Services
public SidebarController(
CustomTaskPaneCollection taskPanes,
Outlook.Application app,
IMatchingService matchingService,
AddInHost host,
Dispatcher dispatcher,
ILogger logger)
{
_taskPanes = taskPanes ?? throw new ArgumentNullException(nameof(taskPanes));
_app = app ?? throw new ArgumentNullException(nameof(app));
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_host = host ?? throw new ArgumentNullException(nameof(host));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -83,7 +80,7 @@ namespace OutlookAddin.Services
if (_bindings.ContainsKey(key)) return;
var view = new ReadingPaneView();
var vm = new ReadingPaneViewModel(_matchingService, _logger)
var vm = new ReadingPaneViewModel(() => _host.MatchingService, _logger)
{
EspoCrmBaseUrl = _host.Settings.EspoCrmUrl
};