Compare commits

...

10 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
19 changed files with 682 additions and 156 deletions
+13 -7
View File
@@ -258,15 +258,21 @@ jobs:
& msbuild @msbuildArgs & msbuild @msbuildArgs
if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" } if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" }
# Sanity check: did the VSTO publish target produce a .vsto file? # Sanity check + rename to canonical OutlookAddin.vsto so the URL
$vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1 # matches PROVIDER_URL (hardcoded to .../OutlookAddin.vsto). Restrict
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" } # 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)" 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') { 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 ===" Write-Host "=== STEP F: stage protocol handler ==="
+5 -10
View File
@@ -315,16 +315,11 @@ git push origin v1.0.1
ה-CI ירוץ אוטומטית כמו בשחרור הראשון. ה-CI ירוץ אוטומטית כמו בשחרור הראשון.
**אצל הלקוחות:** **אצל הלקוחות:**
- ClickOnce בודק עדכון אוטומטית **כל 7 ימים** (הגדרה ב-csproj: `UpdateInterval=7`). - ה־VSTO runtime בודק את ה-`.vsto` manifest **בכל פתיחה של Outlook** (משוחזר מ-`<UpdatePeriodically>false</UpdatePeriodically>` ב-csproj החל מ-v1.2.9; קודם זה היה פעם ב-7 ימים). הבדיקה היא GET של ~6KB, ~100ms — בלתי מורגש בזמן הטעינה.
- כדי **להאיץ** עדכון אצל לקוח מסויים: - ה-CI מציב `/p:MinimumRequiredVersion=<tag>` בכל build, אז מיד כשהבדיקה מזהה גרסה חדשה — ההתקנה נכפית בלי דיאלוג.
1. סגור את Outlook לגמרי. - **תרגום מעשי**: 30 שניות אחרי `git push --tags` ה-CI מסיים → תוך 2-3 דקות החבילה נדחפת ל-CDN → בכניסה הבאה של כל אחד מהלקוחות ל-Outlook הוא רץ על הגרסה החדשה. **בלי שום פעולה ידנית מצידם**.
2. פתח את Outlook שוב. - **הכפתור "בדוק עדכונים" בהגדרות** רק מודיע ("זמינה גרסה X — סגור ופתח Outlook"). הוא לא יכול להתקין מתוך תהליך Outlook רץ — `VSTOInstaller.exe` לא יכול לדרוס DLLs נעולים, וה-ApplicationDeployment API נכשל ב-`TrustNotGrantedException` בהקשר VSTO. ההתקנה תמיד קורית רק דרך ה-runtime בעלייה של Outlook. [פירוט בזיכרון: feedback_vsto_updater_api.md]
3. אם עברו לפחות 7 ימים מבדיקת ה-update הקודמת — יבדוק עכשיו ויעדכן. - **תקלה נדירה — לקוח שהותקן ידנית עם 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 עובד.
4. אם לא — אפשר לנקות את ה-ClickOnce cache:
```powershell
rundll32.exe dfshim.dll,CleanOnlineAppCache
```
ואז לפתוח שוב את `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`.
--- ---
+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`) - 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 - 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 - 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. **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 // is sent in the "file" field as a data-URI. The previous extra
// "contents" alias was an undocumented legacy that strict tenants // "contents" alias was an undocumented legacy that strict tenants
// reject with 400. // 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?> var payload = new Dictionary<string, object?>
{ {
["name"] = fileName, ["name"] = fileName,
["type"] = mime, ["type"] = mime,
["role"] = "Attachment", ["role"] = "Attachment",
["relatedType"] = "Document", ["parentType"] = "Note",
["field"] = "file", ["field"] = "attachments",
["file"] = dataUri ["file"] = dataUri
}; };
@@ -1,9 +1,10 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog" <Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="כתוב מייל מתיק" Title="כתוב מייל מתיק"
Height="480" Width="600" Height="520" Width="680"
MinHeight="320" MinWidth="500" MinHeight="360" MinWidth="540"
FlowDirection="RightToLeft" FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
ShowInTaskbar="False" ShowInTaskbar="False"
@@ -11,6 +12,7 @@
Background="#F8FAFC" Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial"> FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources> <Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" /> <SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" /> <SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" /> <SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -134,24 +136,30 @@
</Border> </Border>
<Border Grid.Row="1" Style="{StaticResource Card}"> <Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}" <DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}" SelectedItem="{Binding SelectedCase}"
BorderThickness="0" AutoGenerateColumns="False"
Background="Transparent" HeadersVisibility="Column"
ItemContainerStyle="{StaticResource ResultItem}" GridLinesVisibility="Horizontal"
Padding="4"> HorizontalGridLinesBrush="#E2E8F0"
<ListBox.ItemTemplate> RowBackground="White"
<DataTemplate> AlternatingRowBackground="#F8FAFC"
<StackPanel> BorderThickness="0"
<TextBlock FontSize="13"> CanUserAddRows="False"
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " /> CanUserDeleteRows="False"
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" /> CanUserResizeRows="False"
</TextBlock> CanUserReorderColumns="False"
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" /> SelectionMode="Single"
</StackPanel> SelectionUnit="FullRow"
</DataTemplate> IsReadOnly="True"
</ListBox.ItemTemplate> RowHeight="30">
</ListBox> <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> </Border>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" <TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters" xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="שמור קבצים מצורפים ל-Klear" Title="שמור קבצים מצורפים ל-Klear"
Height="560" Width="620" Height="640" Width="620"
MinHeight="420" MinWidth="500" MinHeight="480" MinWidth="500"
FlowDirection="RightToLeft" FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
ShowInTaskbar="False" ShowInTaskbar="False"
@@ -144,24 +144,38 @@
<!-- Folder --> <!-- Folder -->
<DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True"> <DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center" <DockPanel DockPanel.Dock="Top" LastChildFill="True" Margin="0,0,0,4">
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" /> <TextBlock DockPanel.Dock="Left" Text="תיקיית יעד:" VerticalAlignment="Center"
<TextBlock DockPanel.Dock="Right" Text="טוען…" VerticalAlignment="Center" Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
Margin="8,0,0,0" Foreground="{StaticResource Muted}" FontSize="12" <TextBlock DockPanel.Dock="Right" Text="טוען…" VerticalAlignment="Center"
Visibility="{Binding IsLoadingSubfolders, Converter={StaticResource BoolToVis}}" /> Margin="8,0,0,0" Foreground="{StaticResource Muted}" FontSize="12"
<ComboBox ItemsSource="{Binding Subfolders}" Visibility="{Binding IsLoadingSubfolders, Converter={StaticResource BoolToVis}}" />
SelectedItem="{Binding SelectedSubfolder}" <TextBlock Text="בחר/י תיקייה או תת-תיקייה לשמירה (ניתן להרחיב רמות)"
Padding="6,4"> VerticalAlignment="Center" Foreground="{StaticResource Muted}" FontSize="11"
<ComboBox.Style> TextTrimming="CharacterEllipsis" />
<Style TargetType="ComboBox"> </DockPanel>
<Style.Triggers> <Border Style="{StaticResource Card}" MaxHeight="150">
<DataTrigger Binding="{Binding IsLoadingSubfolders}" Value="True"> <TreeView x:Name="FolderTreeView"
<Setter Property="IsEnabled" Value="False" /> ItemsSource="{Binding FolderTree}"
</DataTrigger> BorderThickness="0"
</Style.Triggers> Background="Transparent"
</Style> SelectedItemChanged="FolderTreeView_SelectedItemChanged">
</ComboBox.Style> <TreeView.ItemContainerStyle>
</ComboBox> <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> </DockPanel>
<!-- Attachments header --> <!-- Attachments header -->
@@ -32,5 +32,15 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs
DialogResult = _viewModel?.DialogResult; DialogResult = _viewModel?.DialogResult;
Close(); 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" /> MinWidth="100" />
<Button Command="{Binding CancelCommand}" <Button Command="{Binding CancelCommand}"
IsCancel="True" IsCancel="True"
Content="ביטול" Content="סגור"
Padding="20,5" Padding="20,5"
MinWidth="100" /> MinWidth="100" />
</StackPanel> </StackPanel>
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
Cases.Clear(); Cases.Clear();
int kept = 0; var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List) foreach (var hit in search.List)
{ {
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue; if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
Cases.Add(new CaseEntity caseHits.Add(hit);
{
Id = hit.Id,
Name = hit.Name,
});
kept++;
} }
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)" if (caseHits.Count == 0)
: $"נמצאו {kept} תיקים"; {
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 (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException) 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))] [RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit() 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 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 readonly ILogger _logger;
private CancellationTokenSource? _activeLookup; private CancellationTokenSource? _activeLookup;
// Stashes the ambiguous MatchResult while the user is drilling into // Stashes the ambiguous MatchResult while the user is drilling into
@@ -94,9 +99,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary> /// </summary>
public event EventHandler<string>? OpenInBrowserRequested; 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)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
@@ -162,12 +167,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
_ambiguousReturnTarget = null; _ambiguousReturnTarget = null;
State = ReadingPaneState.Loading; State = ReadingPaneState.Loading;
var svc = _matchingServiceProvider();
if (svc == null)
{
ErrorMessage = "התוסף עדיין לא מוגדר. עדכן בהגדרות.";
State = ReadingPaneState.Error;
return;
}
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_activeLookup = cts; _activeLookup = cts;
try 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 (cts.IsCancellationRequested) return;
if (result == null) if (result == null)
@@ -296,6 +309,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{ {
if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return; if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return;
var svc = _matchingServiceProvider();
if (svc == null)
{
ErrorMessage = "התוסף עדיין לא מוגדר. עדכן בהגדרות.";
State = ReadingPaneState.Error;
return;
}
CancelInFlight(); CancelInFlight();
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
_activeLookup = cts; _activeLookup = cts;
@@ -310,7 +331,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
try 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 (cts.IsCancellationRequested) return;
if (result == null || result.Contact == null) if (result == null || result.Contact == null)
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models; using MarcusLaw.OutlookAddin.Core.Models;
@@ -31,6 +32,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IEspoCrmClient _client; private readonly IEspoCrmClient _client;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly Dispatcher _dispatcher;
private CancellationTokenSource? _searchCts; private CancellationTokenSource? _searchCts;
[ObservableProperty] [ObservableProperty]
@@ -40,7 +42,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private CaseEntity? selectedCase; private CaseEntity? selectedCase;
[ObservableProperty] [ObservableProperty]
private string? selectedSubfolder; private FolderNodeViewModel? selectedFolder;
[ObservableProperty] [ObservableProperty]
private string? statusMessage; private string? statusMessage;
@@ -58,7 +60,12 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>(); 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>(); public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
@@ -78,14 +85,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
} }
/// <summary> /// <summary>
/// Subfolder string the user picked, or null to save to the case /// Folder the user picked, as a path relative to the case folder
/// folder root. /// (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> /// </summary>
public string? ChosenSubfolderName => public string? ChosenSubfolderName
string.IsNullOrEmpty(SelectedSubfolder) || {
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal) get
? null {
: SelectedSubfolder; var f = SelectedFolder;
if (f == null || f.IsPlaceholder || f.IsRoot) return null;
return string.IsNullOrEmpty(f.RelativePath) ? null : f.RelativePath;
}
}
public CaseEntity? PickedCase => SelectedCase; public CaseEntity? PickedCase => SelectedCase;
@@ -98,8 +111,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{ {
_client = client ?? throw new ArgumentNullException(nameof(client)); _client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _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) 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(); if (cases == null || cases.Count == 0) return;
Subfolders.Add(RootSentinel);
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f); Cases.Clear();
SelectedSubfolder = Subfolders[0]; 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) partial void OnSearchTextChanged(string value)
@@ -137,7 +197,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary> /// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync() public async Task RefreshSubfoldersForSelectedCaseAsync()
{ {
ResetSubfolders(); ResetFolderTree();
ResolvedCaseFolderPath = null; ResolvedCaseFolderPath = null;
var c = SelectedCase; var c = SelectedCase;
@@ -188,21 +248,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath); c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
if (string.IsNullOrEmpty(folderPath)) return; 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 try
{ {
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true); await root.LoadChildrenAsync(DefaultCaseSubfolders).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);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id); _logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
} }
OnUi(() => root.IsExpanded = true);
} }
/// <summary> /// <summary>
+4 -1
View File
@@ -36,7 +36,7 @@
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl> <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> <InstallUrl>https://gitea.dev.marcus-law.co.il/espocrm-extensions/OutlookAddin/raw/branch/main/Publish/</InstallUrl>
<TargetCulture>en</TargetCulture> <TargetCulture>en</TargetCulture>
<ApplicationVersion>1.2.9.0</ApplicationVersion> <ApplicationVersion>1.2.10.0</ApplicationVersion>
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision> <AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
<UpdateEnabled>true</UpdateEnabled> <UpdateEnabled>true</UpdateEnabled>
<UpdatePeriodically>false</UpdatePeriodically> <UpdatePeriodically>false</UpdatePeriodically>
@@ -257,6 +257,9 @@
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml"> <EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName> <LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
</EmbeddedResource> </EmbeddedResource>
<EmbeddedResource Include="Ribbon\ReadMailRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.ReadMailRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Images\klear-file.png"> <EmbeddedResource Include="Images\klear-file.png">
<LogicalName>OutlookAddin.Images.klear-file.png</LogicalName> <LogicalName>OutlookAddin.Images.klear-file.png</LogicalName>
</EmbeddedResource> </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 // You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below: // by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")] // [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.2.9.0")] [assembly: AssemblyVersion("1.2.17.0")]
[assembly: AssemblyFileVersion("1.2.9.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 ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.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 // Every button whose `getEnabled` depends on the current Explorer
// selection must be re-evaluated on every SelectionChange — Office // selection must be re-evaluated on every SelectionChange — Office
@@ -40,6 +41,8 @@ namespace OutlookAddin.Ribbon
return LoadResource(ExplorerResource); return LoadResource(ExplorerResource);
case "Microsoft.Outlook.Mail.Compose": case "Microsoft.Outlook.Mail.Compose":
return LoadResource(InspectorResource); return LoadResource(InspectorResource);
case "Microsoft.Outlook.Mail.Read":
return LoadResource(ReadMailResource);
default: default:
return string.Empty; return string.Empty;
} }
@@ -77,6 +80,51 @@ namespace OutlookAddin.Ribbon
_inspectorRibbon = 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) public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
{ {
try try
@@ -206,6 +254,7 @@ namespace OutlookAddin.Ribbon
{ {
case "FileToEspoCrm": fileName = "klear-file.png"; break; case "FileToEspoCrm": fileName = "klear-file.png"; break;
case "SaveAttachments": fileName = "klear-attach.png"; break; case "SaveAttachments": fileName = "klear-attach.png"; break;
case "SaveAttachmentsRead": 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;
@@ -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() private static void EnableModernTls()
{ {
try try
@@ -311,7 +367,6 @@ namespace OutlookAddin.Services
_sidebarController = new SidebarController( _sidebarController = new SidebarController(
taskPanes, taskPanes,
_outlookApp, _outlookApp,
MatchingService!,
this, this,
System.Windows.Threading.Dispatcher.CurrentDispatcher, System.Windows.Threading.Dispatcher.CurrentDispatcher,
Logger); Logger);
@@ -402,9 +457,8 @@ namespace OutlookAddin.Services
if (mail == null) return; if (mail == null) return;
if (!IsConfigured) if (!IsConfigured)
{ {
MessageBox.Show( ShowMessage(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.", "התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -423,9 +477,8 @@ namespace OutlookAddin.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex, "LaunchComposeFromCaseAsync failed"); Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
MessageBox.Show( ShowMessage(
"טעינת התיק נכשלה: " + ex.Message, "טעינת התיק נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
} }
@@ -444,9 +497,8 @@ namespace OutlookAddin.Services
if (mail == null) return; if (mail == null) return;
if (!IsConfigured) if (!IsConfigured)
{ {
var result = MessageBox.Show( var result = ShowMessage(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?", "התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Klear",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Information); MessageBoxImage.Information);
if (result == MessageBoxResult.Yes) LaunchSettings(); if (result == MessageBoxResult.Yes) LaunchSettings();
@@ -463,9 +515,8 @@ namespace OutlookAddin.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed"); Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
MessageBox.Show( ShowMessage(
"קריאת הקבצים המצורפים נכשלה: " + ex.Message, "קריאת הקבצים המצורפים נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
return; return;
@@ -473,9 +524,8 @@ namespace OutlookAddin.Services
if (envelope.Attachments.Count == 0) if (envelope.Attachments.Count == 0)
{ {
MessageBox.Show( ShowMessage(
"אין במייל הזה קבצים מצורפים לשמירה.", "אין במייל הזה קבצים מצורפים לשמירה.",
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -485,6 +535,13 @@ namespace OutlookAddin.Services
// Subfolders are populated lazily once the user picks a case; // Subfolders are populated lazily once the user picks a case;
// the default static set is already pre-loaded. // 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); var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog); AttachOwnerToOutlook(dialog);
var ok = dialog.ShowDialog(); var ok = dialog.ShowDialog();
@@ -503,9 +560,8 @@ namespace OutlookAddin.Services
: SaveAttachmentsViewModel.ResolveCaseFolderPath(picked); : SaveAttachmentsViewModel.ResolveCaseFolderPath(picked);
if (string.IsNullOrEmpty(caseFolder)) if (string.IsNullOrEmpty(caseFolder))
{ {
MessageBox.Show( ShowMessage(
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.", "לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; return;
@@ -533,15 +589,38 @@ namespace OutlookAddin.Services
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed"); Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
MessageBox.Show( ShowMessage(
"שמירת הקבצים נכשלה: " + ex.Message, "שמירת הקבצים נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); 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; string message;
MessageBoxImage icon; MessageBoxImage icon;
@@ -558,7 +637,7 @@ namespace OutlookAddin.Services
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts); message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information; icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
} }
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon); ShowMessage(message, MessageBoxButton.OK, icon);
} }
/// <summary> /// <summary>
@@ -570,9 +649,8 @@ namespace OutlookAddin.Services
{ {
if (!IsConfigured) if (!IsConfigured)
{ {
MessageBox.Show( ShowMessage(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.", "התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -589,9 +667,8 @@ namespace OutlookAddin.Services
{ {
Logger.Error(ex, "LaunchComposeFromCaseByIdAsync failed for caseId={CaseId}", caseId); Logger.Error(ex, "LaunchComposeFromCaseByIdAsync failed for caseId={CaseId}", caseId);
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { } if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
MessageBox.Show( ShowMessage(
"פתיחת המייל מתיק נכשלה: " + ex.Message, "פתיחת המייל מתיק נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
} }
@@ -654,9 +731,8 @@ namespace OutlookAddin.Services
{ {
if (!IsConfigured) if (!IsConfigured)
{ {
var result = MessageBox.Show( var result = ShowMessage(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?", "התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Klear",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Information); MessageBoxImage.Information);
if (result == MessageBoxResult.Yes) if (result == MessageBoxResult.Yes)
@@ -669,9 +745,8 @@ namespace OutlookAddin.Services
var mailItems = ReadCurrentSelection(); var mailItems = ReadCurrentSelection();
if (mailItems.Count == 0) if (mailItems.Count == 0)
{ {
MessageBox.Show( ShowMessage(
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.", "לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -712,9 +787,8 @@ namespace OutlookAddin.Services
Logger.Warning( Logger.Warning(
"Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})", "Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})",
vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType); vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType);
MessageBox.Show( ShowMessage(
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.", "התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
"Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; 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; string message;
MessageBoxImage icon; MessageBoxImage icon;
@@ -907,7 +981,7 @@ namespace OutlookAddin.Services
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information; 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) 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)); 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); var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
ContactEntity? primaryContact = null; var recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
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 guid = Guid.NewGuid().ToString("N"); var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id); StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
try try
{ {
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress)) if (!string.IsNullOrWhiteSpace(recipientEmail))
{ {
var existingTo = mail.To ?? string.Empty; var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!)) if (!existingTo.Contains(recipientEmail!))
{ {
mail.To = string.IsNullOrEmpty(existingTo) mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress ? recipientEmail
: existingTo + "; " + primaryContact.EmailAddress; : existingTo + "; " + recipientEmail;
} }
} }
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id)); 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? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty); public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty); public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);
@@ -27,7 +27,6 @@ namespace OutlookAddin.Services
private readonly CustomTaskPaneCollection _taskPanes; private readonly CustomTaskPaneCollection _taskPanes;
private readonly Outlook.Application _app; private readonly Outlook.Application _app;
private readonly IMatchingService _matchingService;
private readonly AddInHost _host; private readonly AddInHost _host;
private readonly Dispatcher _dispatcher; private readonly Dispatcher _dispatcher;
private readonly ILogger _logger; private readonly ILogger _logger;
@@ -38,14 +37,12 @@ namespace OutlookAddin.Services
public SidebarController( public SidebarController(
CustomTaskPaneCollection taskPanes, CustomTaskPaneCollection taskPanes,
Outlook.Application app, Outlook.Application app,
IMatchingService matchingService,
AddInHost host, AddInHost host,
Dispatcher dispatcher, Dispatcher dispatcher,
ILogger logger) ILogger logger)
{ {
_taskPanes = taskPanes ?? throw new ArgumentNullException(nameof(taskPanes)); _taskPanes = taskPanes ?? throw new ArgumentNullException(nameof(taskPanes));
_app = app ?? throw new ArgumentNullException(nameof(app)); _app = app ?? throw new ArgumentNullException(nameof(app));
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
_host = host ?? throw new ArgumentNullException(nameof(host)); _host = host ?? throw new ArgumentNullException(nameof(host));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -83,7 +80,7 @@ namespace OutlookAddin.Services
if (_bindings.ContainsKey(key)) return; if (_bindings.ContainsKey(key)) return;
var view = new ReadingPaneView(); var view = new ReadingPaneView();
var vm = new ReadingPaneViewModel(_matchingService, _logger) var vm = new ReadingPaneViewModel(() => _host.MatchingService, _logger)
{ {
EspoCrmBaseUrl = _host.Settings.EspoCrmUrl EspoCrmBaseUrl = _host.Settings.EspoCrmUrl
}; };