Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a53da59f | |||
| 6a51a58224 | |||
| 9b451c9cbe | |||
| 35ce8facff | |||
| c685f4f91d | |||
| 5eae3da213 | |||
| 8264c11b57 | |||
| 4556d2c1b3 | |||
| a8e947c825 | |||
| 32f6facb67 | |||
| 2228353c48 | |||
| eea0d92d85 | |||
| 655fefa822 |
@@ -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 ==="
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -214,42 +214,25 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VSTO add-ins cannot be updated programmatically from inside
|
// A running Outlook holds file locks on the loaded add-in
|
||||||
// a running Outlook — ApplicationDeployment.Update() throws.
|
// DLLs. Neither ApplicationDeployment.Update (TrustNotGranted)
|
||||||
// Don't shell-launch the .vsto URL either: Chrome / Edge will
|
// nor a direct VSTOInstaller.exe /I call (0x8007007E
|
||||||
// download it to disk, and the resulting file has a different
|
// ERROR_MOD_NOT_FOUND) nor Process.Start(.vsto URL) (Chrome
|
||||||
// security zone than the deployment manifest, which fails with
|
// downloads to local-machine zone — InvalidDeploymentException)
|
||||||
// InvalidDeploymentException ("no matching security zones").
|
// can install over those locks. The only path that actually
|
||||||
// Call VSTOInstaller.exe directly so the URL is processed in
|
// works is the one VSTO runtime takes automatically on Outlook
|
||||||
// the Internet zone, matching the manifest.
|
// startup: it checks the manifest URL before loading the
|
||||||
var installerPath = FindVstoInstaller();
|
// add-in, downloads + installs if there's a newer version,
|
||||||
if (installerPath != null)
|
// then loads it.
|
||||||
{
|
//
|
||||||
try
|
// So the button's job is just to tell the user: "close and
|
||||||
{
|
// reopen Outlook." That's the same flow the user has been
|
||||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
|
// doing manually all along; we're just confirming the new
|
||||||
installerPath, "/I \"" + manifestUri + "\"")
|
// version is there waiting.
|
||||||
{
|
UpdateStatusBrush = SuccessBrush;
|
||||||
UseShellExecute = false
|
UpdateStatus =
|
||||||
});
|
$"זמינה גרסה {availableVersion}. סגור ופתח את Outlook — " +
|
||||||
UpdateStatusBrush = SuccessBrush;
|
"הגרסה החדשה תותקן אוטומטית בעלייה.";
|
||||||
UpdateStatus = $"זמינה גרסה {availableVersion} — אשר את ההתקנה בחלון שנפתח, ואז סגור ופתח את Outlook.";
|
|
||||||
}
|
|
||||||
catch (Exception launchEx)
|
|
||||||
{
|
|
||||||
_logger.Warning(launchEx, "CheckForUpdate: VSTOInstaller launch failed");
|
|
||||||
UpdateStatusBrush = ErrorBrush;
|
|
||||||
UpdateStatus = $"זמינה גרסה {availableVersion} — סגור ופתח את Outlook כדי להתקין אוטומטית.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// VSTOInstaller not at the canonical path — VSTO runtime
|
|
||||||
// still picks up new manifests on Outlook startup, so
|
|
||||||
// ask the user to restart instead of attempting a hack.
|
|
||||||
UpdateStatusBrush = SuccessBrush;
|
|
||||||
UpdateStatus = $"זמינה גרסה {availableVersion} — סגור ופתח את Outlook כדי להתקין אוטומטית.";
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (System.Deployment.Application.DeploymentDownloadException ex)
|
catch (System.Deployment.Application.DeploymentDownloadException ex)
|
||||||
@@ -303,50 +286,6 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Locates VSTOInstaller.exe on the machine. The path is stable
|
|
||||||
/// for VSTO 4.0 (Office 2010+) — falls back to a registry lookup
|
|
||||||
/// if the default install location was customised.
|
|
||||||
/// </summary>
|
|
||||||
private static string? FindVstoInstaller()
|
|
||||||
{
|
|
||||||
string[] candidates =
|
|
||||||
{
|
|
||||||
@"C:\Program Files (x86)\Common Files\Microsoft Shared\VSTO\10.0\VSTOInstaller.exe",
|
|
||||||
@"C:\Program Files\Common Files\Microsoft Shared\VSTO\10.0\VSTOInstaller.exe"
|
|
||||||
};
|
|
||||||
foreach (var p in candidates)
|
|
||||||
{
|
|
||||||
if (System.IO.File.Exists(p)) return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registry fallback: HKCR\VstoFile\shell\Open\command stores
|
|
||||||
// the verb VSTOInstaller registers for the .vsto extension.
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (var key = Microsoft.Win32.Registry.ClassesRoot.OpenSubKey(@"VstoFile\shell\Open\command"))
|
|
||||||
{
|
|
||||||
var cmd = key?.GetValue(null) as string;
|
|
||||||
if (!string.IsNullOrEmpty(cmd))
|
|
||||||
{
|
|
||||||
// Value looks like: "C:\...\VSTOInstaller.exe" "%1"
|
|
||||||
var firstQuote = cmd!.IndexOf('"');
|
|
||||||
if (firstQuote == 0)
|
|
||||||
{
|
|
||||||
var endQuote = cmd.IndexOf('"', 1);
|
|
||||||
if (endQuote > 1)
|
|
||||||
{
|
|
||||||
var exe = cmd.Substring(1, endQuote - 1);
|
|
||||||
if (System.IO.File.Exists(exe)) return exe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* registry access denied or schema differs — fall through */ }
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CanCheckForUpdate() => !IsCheckingForUpdate;
|
private bool CanCheckForUpdate() => !IsCheckingForUpdate;
|
||||||
|
|
||||||
partial void OnIsCheckingForUpdateChanged(bool value)
|
partial void OnIsCheckingForUpdateChanged(bool value)
|
||||||
|
|||||||
@@ -36,11 +36,10 @@
|
|||||||
<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.6.0</ApplicationVersion>
|
<ApplicationVersion>1.2.10.0</ApplicationVersion>
|
||||||
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
|
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
|
||||||
<UpdateEnabled>true</UpdateEnabled>
|
<UpdateEnabled>true</UpdateEnabled>
|
||||||
<UpdateInterval>7</UpdateInterval>
|
<UpdatePeriodically>false</UpdatePeriodically>
|
||||||
<UpdateIntervalUnits>days</UpdateIntervalUnits>
|
|
||||||
<ProductName>MarcusLaw.OutlookAddin</ProductName>
|
<ProductName>MarcusLaw.OutlookAddin</ProductName>
|
||||||
<PublisherName />
|
<PublisherName />
|
||||||
<SupportUrl />
|
<SupportUrl />
|
||||||
@@ -258,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>
|
||||||
|
|||||||
@@ -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.6.0")]
|
[assembly: AssemblyVersion("1.2.17.0")]
|
||||||
[assembly: AssemblyFileVersion("1.2.6.0")]
|
[assembly: AssemblyFileVersion("1.2.17.0")]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user