Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35ce8facff | |||
| c685f4f91d | |||
| 5eae3da213 | |||
| 8264c11b57 | |||
| 4556d2c1b3 | |||
| a8e947c825 | |||
| 32f6facb67 | |||
| 2228353c48 | |||
| eea0d92d85 | |||
| 655fefa822 | |||
| d2c54840ba | |||
| 870426ec08 | |||
| 137929b4ac | |||
| 95d7599cb1 | |||
| 62254fcd8c | |||
| 7aa96c99a3 | |||
| 35455c2ef6 |
@@ -258,15 +258,21 @@ jobs:
|
||||
& msbuild @msbuildArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" }
|
||||
|
||||
# Sanity check: did the VSTO publish target produce a .vsto file?
|
||||
$vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1
|
||||
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" }
|
||||
# Sanity check + rename to canonical OutlookAddin.vsto so the URL
|
||||
# matches PROVIDER_URL (hardcoded to .../OutlookAddin.vsto). Restrict
|
||||
# to publish/ root -- there's a second .vsto inside Application Files
|
||||
# that must keep its versioned name. Verify the renamed file exists
|
||||
# before continuing; v1.2.11 shipped broken because a silent failure
|
||||
# here left no OutlookAddin.vsto in the tar and 404'd the CDN.
|
||||
$vsto = Get-ChildItem -File -Filter '*.vsto' -Path publish | Select-Object -First 1
|
||||
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found at publish/ root" }
|
||||
Write-Host " produced $($vsto.FullName)"
|
||||
|
||||
# Rename to canonical OutlookAddin.vsto so the ClickOnce manifest
|
||||
# URL matches PROVIDER_URL (which is hardcoded to .../OutlookAddin.vsto).
|
||||
if ($vsto.Name -ne 'OutlookAddin.vsto') {
|
||||
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto'
|
||||
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto' -ErrorAction Stop
|
||||
Write-Host " renamed -> OutlookAddin.vsto"
|
||||
}
|
||||
if (-not (Test-Path 'publish/OutlookAddin.vsto')) {
|
||||
throw "post-rename: publish/OutlookAddin.vsto does not exist"
|
||||
}
|
||||
|
||||
Write-Host "=== STEP F: stage protocol handler ==="
|
||||
|
||||
@@ -315,16 +315,11 @@ git push origin v1.0.1
|
||||
ה-CI ירוץ אוטומטית כמו בשחרור הראשון.
|
||||
|
||||
**אצל הלקוחות:**
|
||||
- ClickOnce בודק עדכון אוטומטית **כל 7 ימים** (הגדרה ב-csproj: `UpdateInterval=7`).
|
||||
- כדי **להאיץ** עדכון אצל לקוח מסויים:
|
||||
1. סגור את Outlook לגמרי.
|
||||
2. פתח את Outlook שוב.
|
||||
3. אם עברו לפחות 7 ימים מבדיקת ה-update הקודמת — יבדוק עכשיו ויעדכן.
|
||||
4. אם לא — אפשר לנקות את ה-ClickOnce cache:
|
||||
```powershell
|
||||
rundll32.exe dfshim.dll,CleanOnlineAppCache
|
||||
```
|
||||
ואז לפתוח שוב את `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`.
|
||||
- ה־VSTO runtime בודק את ה-`.vsto` manifest **בכל פתיחה של Outlook** (משוחזר מ-`<UpdatePeriodically>false</UpdatePeriodically>` ב-csproj החל מ-v1.2.9; קודם זה היה פעם ב-7 ימים). הבדיקה היא GET של ~6KB, ~100ms — בלתי מורגש בזמן הטעינה.
|
||||
- ה-CI מציב `/p:MinimumRequiredVersion=<tag>` בכל build, אז מיד כשהבדיקה מזהה גרסה חדשה — ההתקנה נכפית בלי דיאלוג.
|
||||
- **תרגום מעשי**: 30 שניות אחרי `git push --tags` ה-CI מסיים → תוך 2-3 דקות החבילה נדחפת ל-CDN → בכניסה הבאה של כל אחד מהלקוחות ל-Outlook הוא רץ על הגרסה החדשה. **בלי שום פעולה ידנית מצידם**.
|
||||
- **הכפתור "בדוק עדכונים" בהגדרות** רק מודיע ("זמינה גרסה X — סגור ופתח Outlook"). הוא לא יכול להתקין מתוך תהליך Outlook רץ — `VSTOInstaller.exe` לא יכול לדרוס DLLs נעולים, וה-ApplicationDeployment API נכשל ב-`TrustNotGrantedException` בהקשר VSTO. ההתקנה תמיד קורית רק דרך ה-runtime בעלייה של Outlook. [פירוט בזיכרון: feedback_vsto_updater_api.md]
|
||||
- **תקלה נדירה — לקוח שהותקן ידנית עם dev cert** (`HP-OFFICE1\<user>`, publicKeyToken `41d8d795775ea8cb`) לא יכול לעבור auto-update לגרסת prod cert (`Marcus-Law OutlookAddin`, publicKeyToken `c68d2b4c25051c5b`) — ClickOnce מתייחס אליהן כשתי אפליקציות שונות. **פעם אחת**: Apps & Features → uninstall של MarcusLaw.OutlookAddin → התקנה חדשה מ-`https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`. אחרי זה auto-update עובד.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -268,14 +268,47 @@ If the session is on the Linux dev server (`/home/chaim/espocrm-extensions/Outlo
|
||||
|
||||
---
|
||||
|
||||
## How releases work (current, 2026-05-24)
|
||||
|
||||
**Production install URL:** `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`
|
||||
|
||||
**Release flow:**
|
||||
|
||||
1. Code change merged to `main` (push or PR-merge — no CI fires yet)
|
||||
2. Bump `<ApplicationVersion>` in `src/OutlookAddin/OutlookAddin.csproj` and `AssemblyVersion` + `AssemblyFileVersion` in `src/OutlookAddin/Properties/AssemblyInfo.cs` to the next 4-part version (e.g. `1.2.1.0`). CI overrides `ApplicationVersion` from the tag at build time, but leaves AssemblyInfo alone — keep them in sync manually so the bundled DLL's FileVersion matches the manifest.
|
||||
3. `git tag -a v1.2.1 -m "..." && git push --tags`
|
||||
4. Gitea Actions workflow (`.gitea/workflows/build.yml`) fires:
|
||||
- Imports the **real** codesign cert from secrets `CODESIGN_CERT_PFX_BASE64` + `CODESIGN_THUMBPRINT` (publicKeyToken `c68d2b4c25051c5b`, `CN=Marcus-Law OutlookAddin`)
|
||||
- Patches csproj to swap the dev self-signed thumbprint (`2399E9BF…`) for the real one
|
||||
- `msbuild /t:Publish` with `/p:InstallUrl=https://platform.dev.marcus-law.co.il/outlook-addin/` and `/p:ApplicationVersion=<tag>.0`
|
||||
- Uploads `publish.tar.gz` to Gitea generic registry (`outlook-addin-publish/<version>/publish.tar.gz`)
|
||||
- Linux job downloads the tar.gz, builds a Docker image `outlook-addin-cdn:latest`, pushes to Gitea registry, calls Coolify to redeploy the CDN container
|
||||
- Posts to Mattermost channel `git-verelasim` with the install URL on success / a failure link on failure
|
||||
5. Lawyers' installed Add-in checks the deployment manifest at the install URL **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.
|
||||
|
||||
**Branding (`KlearBranding` repo):** the product is presented to lawyers as **Klear** — title bars, MessageBox titles, ribbon group label, About header all say "Klear". The logo is mirrored from `espocrm-extensions/KlearBranding/files/client/custom/img/logo-klear.png` into `src/OutlookAddin.UI/Resources/logo-klear.png` and embedded as a WPF Resource. Brand accent color: `#121d2e` (dark navy).
|
||||
|
||||
**What stays MarcusLaw under the hood (deliberate — preserves auto-update across the rebrand):**
|
||||
- ClickOnce app identity (`AssemblyName=MarcusLaw.OutlookAddin`)
|
||||
- `%APPDATA%\MarcusLaw\OutlookAddin\settings.json` and `creds.dat`
|
||||
- `%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs\`
|
||||
- `DpapiCredentialStore.EntropySeed = "MarcusLaw.OutlookAddin.v1"`
|
||||
- Code namespaces `MarcusLaw.OutlookAddin.*`
|
||||
- Named pipe `MarcusLaw.OutlookAddin`
|
||||
|
||||
Changing any of these would orphan all existing installs and require a coordinated uninstall+reinstall on every lawyer's PC plus an entropy migration for DPAPI-encrypted credentials. See the LegalCRM rebrand misadventure in git history for what *not* to do.
|
||||
|
||||
**Known traps when releasing:**
|
||||
- VS Publish wizard (Project Properties → Publish) **rewrites** csproj at every click — flips `<AutoIncrementApplicationRevision>` back to `true`, resets `<ApplicationVersion>` to whatever's in the UI, sometimes deletes prior `Application Files/<...>/` bundles. **Don't use it.** Tag and let CI do it.
|
||||
- There's an old `Publish/` directory in git history that was a parallel manual-release channel via `gitea.../raw/branch/main/Publish/`. It's **not the production install URL** — that's platform.dev. The folder was dropped in v1.2.0.
|
||||
|
||||
## Things deliberately deferred
|
||||
|
||||
| What | When | Why deferred |
|
||||
|---|---|---|
|
||||
| Windows CI runner on Coolify | Until ClickOnce publish becomes useful | Local builds on dev's VS 2022 suffice through week 5 |
|
||||
| Code signing cert (self-signed) + GPO push to 10 PCs | Week 6 (before release) | IT task; cert isn't useful until we have something to sign |
|
||||
| ClickOnce hosting setup at `platform.dev.marcus-law.co.il/outlook-addin/` | Week 6 | No build artifacts yet |
|
||||
| Mattermost release notification webhook in CI | Week 6 | Coupled to CI runner |
|
||||
| Identity-level rebrand to a pure Klear product (AssemblyName, namespaces, DPAPI entropy, AppData paths) | Until there's another reason to force-reinstall on all 10 PCs | Visible rebrand already done in v1.2.0 without disrupting users; full rename trades aesthetic cleanliness for a day of IT coordination + lost credentials |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -213,13 +213,29 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
// is sent in the "file" field as a data-URI. The previous extra
|
||||
// "contents" alias was an undocumented legacy that strict tenants
|
||||
// reject with 400.
|
||||
//
|
||||
// We bind the upload to Note.attachments (parentType=Note,
|
||||
// field=attachments). EspoCRM's attachment access checker now
|
||||
// REJECTS a role=Attachment upload that carries neither `field`
|
||||
// nor `parentType` ("No `field` and `parentType`." → 400) — the
|
||||
// earlier "unbound blob" shape no longer passes. Note.attachments
|
||||
// is the documented Attachment-Multiple example and a built-in
|
||||
// field that carries NO "allowed file types" restriction, so every
|
||||
// file type (PDF, JPEG, PNG, …) passes — unlike Document.file,
|
||||
// which Marcus-Law limits to PDF/Word and which rejected images
|
||||
// with 403 "Not allowed file type".
|
||||
//
|
||||
// We never set parentId / create a Note: the attachment stays a
|
||||
// standalone blob whose id we immediately push to the network
|
||||
// share. The (parentType, field) pair only satisfies the access
|
||||
// checker and selects an unrestricted file-type policy.
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = fileName,
|
||||
["type"] = mime,
|
||||
["role"] = "Attachment",
|
||||
["relatedType"] = "Document",
|
||||
["field"] = "file",
|
||||
["parentType"] = "Note",
|
||||
["field"] = "attachments",
|
||||
["file"] = dataUri
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
|
||||
Title="כתוב מייל מתיק"
|
||||
Height="480" Width="600"
|
||||
MinHeight="320" MinWidth="500"
|
||||
Height="520" Width="680"
|
||||
MinHeight="360" MinWidth="540"
|
||||
FlowDirection="RightToLeft"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ShowInTaskbar="False"
|
||||
@@ -11,6 +12,7 @@
|
||||
Background="#F8FAFC"
|
||||
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
|
||||
<Window.Resources>
|
||||
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
|
||||
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
|
||||
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
|
||||
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
|
||||
@@ -134,24 +136,30 @@
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1" Style="{StaticResource Card}">
|
||||
<ListBox ItemsSource="{Binding Cases}"
|
||||
SelectedItem="{Binding SelectedCase}"
|
||||
BorderThickness="0"
|
||||
Background="Transparent"
|
||||
ItemContainerStyle="{StaticResource ResultItem}"
|
||||
Padding="4">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel>
|
||||
<TextBlock FontSize="13">
|
||||
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
|
||||
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<DataGrid ItemsSource="{Binding Cases}"
|
||||
SelectedItem="{Binding SelectedCase}"
|
||||
AutoGenerateColumns="False"
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="Horizontal"
|
||||
HorizontalGridLinesBrush="#E2E8F0"
|
||||
RowBackground="White"
|
||||
AlternatingRowBackground="#F8FAFC"
|
||||
BorderThickness="0"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
CanUserResizeRows="False"
|
||||
CanUserReorderColumns="False"
|
||||
SelectionMode="Single"
|
||||
SelectionUnit="FullRow"
|
||||
IsReadOnly="True"
|
||||
RowHeight="30">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
|
||||
<DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
|
||||
<DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
|
||||
<DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
|
||||
|
||||
@@ -123,10 +123,22 @@
|
||||
Foreground="{Binding UpdateStatusBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,4,0,0" />
|
||||
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap"
|
||||
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Text="הגדרות: %APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
|
||||
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap">
|
||||
<Run Text="לוגים: " />
|
||||
<Hyperlink Tag="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs"
|
||||
Click="OnOpenFolderHyperlink"
|
||||
ToolTip="פתח את התיקייה ב-Explorer">
|
||||
<Run Text="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
<TextBlock TextWrapping="Wrap">
|
||||
<Run Text="הגדרות: " />
|
||||
<Hyperlink Tag="%APPDATA%\MarcusLaw\OutlookAddin"
|
||||
Click="OnOpenFolderHyperlink"
|
||||
ToolTip="פתח את התיקייה ב-Explorer">
|
||||
<Run Text="%APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Documents;
|
||||
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
||||
|
||||
namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
||||
@@ -40,5 +43,23 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
||||
DialogResult = _viewModel?.DialogResult;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnOpenFolderHyperlink(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!(sender is Hyperlink h) || !(h.Tag is string raw)) return;
|
||||
try
|
||||
{
|
||||
var path = Environment.ExpandEnvironmentVariables(raw);
|
||||
// Create the folder lazily so the link works even on the very
|
||||
// first run, before any log has been written.
|
||||
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", "\"" + path + "\"") { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("פתיחת התיקייה נכשלה: " + ex.Message, "Klear",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
Cases.Clear();
|
||||
int kept = 0;
|
||||
var caseHits = new List<EspoEntityRef>();
|
||||
foreach (var hit in search.List)
|
||||
{
|
||||
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
Cases.Add(new CaseEntity
|
||||
{
|
||||
Id = hit.Id,
|
||||
Name = hit.Name,
|
||||
});
|
||||
kept++;
|
||||
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
|
||||
caseHits.Add(hit);
|
||||
}
|
||||
StatusMessage = kept == 0
|
||||
? "אין תוצאות מסוג תיק (Case)"
|
||||
: $"נמצאו {kept} תיקים";
|
||||
|
||||
if (caseHits.Count == 0)
|
||||
{
|
||||
StatusMessage = "אין תוצאות מסוג תיק (Case)";
|
||||
return;
|
||||
}
|
||||
|
||||
// GlobalSearch only returns id+name. Hydrate each hit with
|
||||
// number+status (and the Hebrew case name when present) so the
|
||||
// picker shows the same columns the file-attachments picker
|
||||
// does.
|
||||
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
|
||||
foreach (var hit in caseHits)
|
||||
{
|
||||
detailTasks.Add(LoadCaseRowAsync(hit, ct));
|
||||
}
|
||||
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var row in details)
|
||||
{
|
||||
if (row != null) Cases.Add(row);
|
||||
}
|
||||
StatusMessage = $"נמצאו {Cases.Count} תיקים";
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded */ }
|
||||
catch (EspoCrmAuthorizationException)
|
||||
@@ -101,6 +118,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
|
||||
{
|
||||
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
|
||||
if (string.IsNullOrEmpty(hit.Id)) return row;
|
||||
try
|
||||
{
|
||||
var detail = await _client.GetCaseAsync(
|
||||
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
|
||||
row.Number = detail.Number;
|
||||
row.Status = detail.Status;
|
||||
row.CourtCaseNumber = detail.CourtCaseNumber;
|
||||
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "ComposeFromCase: hydrating case {CaseId} failed", hit.Id);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private void Submit()
|
||||
{
|
||||
|
||||
@@ -107,6 +107,38 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (cases == null || cases.Count == 0) return;
|
||||
|
||||
Cases.Clear();
|
||||
foreach (var c in cases)
|
||||
{
|
||||
if (c == null || string.IsNullOrEmpty(c.Id)) continue;
|
||||
Cases.Add(c);
|
||||
}
|
||||
if (Cases.Count == 0) return;
|
||||
|
||||
if (Cases.Count == 1)
|
||||
{
|
||||
SelectedCase = Cases[0];
|
||||
StatusMessage = "התיק זוהה אוטומטית מהשולח";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = $"{Cases.Count} תיקים מקושרים לשולח — בחר/י תיק";
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetSubfolders()
|
||||
{
|
||||
Subfolders.Clear();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Media;
|
||||
@@ -178,18 +179,61 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
}
|
||||
|
||||
var deployment = System.Deployment.Application.ApplicationDeployment.CurrentDeployment;
|
||||
var info = await Task.Run(() => deployment.CheckForDetailedUpdate(false)).ConfigureAwait(true);
|
||||
if (!info.UpdateAvailable)
|
||||
var currentVersion = deployment.CurrentVersion;
|
||||
var manifestUri = deployment.UpdateLocation;
|
||||
|
||||
// ApplicationDeployment.CheckForDetailedUpdate triggers a
|
||||
// TrustManager re-evaluation which throws TrustNotGranted
|
||||
// inside a hosted VSTO process even when the publisher and
|
||||
// permissions are unchanged. Bypass it by fetching the
|
||||
// deployment manifest directly and reading the version.
|
||||
Version? availableVersion = null;
|
||||
try
|
||||
{
|
||||
UpdateStatusBrush = SuccessBrush;
|
||||
UpdateStatus = "אתה על הגרסה האחרונה (" + deployment.CurrentVersion + ").";
|
||||
availableVersion = await Task.Run(() => FetchManifestVersion(manifestUri)).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception fetchEx)
|
||||
{
|
||||
_logger.Warning(fetchEx, "CheckForUpdate: manifest fetch failed");
|
||||
UpdateStatusBrush = ErrorBrush;
|
||||
UpdateStatus = "שגיאה בבדיקת השרת: " + fetchEx.Message;
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateStatus = $"זמינה גרסה {info.AvailableVersion} — מוריד…";
|
||||
var result = await Task.Run(() => deployment.Update()).ConfigureAwait(true);
|
||||
if (availableVersion == null)
|
||||
{
|
||||
UpdateStatusBrush = ErrorBrush;
|
||||
UpdateStatus = "לא ניתן לקרוא את גרסת השרת מ-" + manifestUri;
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableVersion <= currentVersion)
|
||||
{
|
||||
UpdateStatusBrush = SuccessBrush;
|
||||
UpdateStatus = "אתה על הגרסה האחרונה (" + currentVersion + ").";
|
||||
return;
|
||||
}
|
||||
|
||||
// A running Outlook holds file locks on the loaded add-in
|
||||
// DLLs. Neither ApplicationDeployment.Update (TrustNotGranted)
|
||||
// nor a direct VSTOInstaller.exe /I call (0x8007007E
|
||||
// ERROR_MOD_NOT_FOUND) nor Process.Start(.vsto URL) (Chrome
|
||||
// downloads to local-machine zone — InvalidDeploymentException)
|
||||
// can install over those locks. The only path that actually
|
||||
// works is the one VSTO runtime takes automatically on Outlook
|
||||
// startup: it checks the manifest URL before loading the
|
||||
// add-in, downloads + installs if there's a newer version,
|
||||
// then loads it.
|
||||
//
|
||||
// So the button's job is just to tell the user: "close and
|
||||
// reopen Outlook." That's the same flow the user has been
|
||||
// doing manually all along; we're just confirming the new
|
||||
// version is there waiting.
|
||||
UpdateStatusBrush = SuccessBrush;
|
||||
UpdateStatus = $"הותקנה גרסה {info.AvailableVersion}. סגור ופתח את Outlook כדי לטעון אותה.";
|
||||
UpdateStatus =
|
||||
$"זמינה גרסה {availableVersion}. סגור ופתח את Outlook — " +
|
||||
"הגרסה החדשה תותקן אוטומטית בעלייה.";
|
||||
return;
|
||||
}
|
||||
catch (System.Deployment.Application.DeploymentDownloadException ex)
|
||||
{
|
||||
@@ -215,6 +259,33 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the top-level <assemblyIdentity> version from the
|
||||
/// .vsto deployment manifest at <paramref name="manifestUri"/>.
|
||||
/// Returns null if the document doesn't look like a deployment
|
||||
/// manifest. Runs on a worker thread (synchronous HTTP).
|
||||
/// </summary>
|
||||
private static Version? FetchManifestVersion(Uri manifestUri)
|
||||
{
|
||||
// The .vsto manifest is small (≈6KB). Keep this synchronous —
|
||||
// no point pulling in HttpClient and its lifecycle just for one
|
||||
// GET. WebRequest works fine on .NET FW 4.8 and through proxies.
|
||||
var req = System.Net.WebRequest.Create(manifestUri);
|
||||
req.Timeout = 10_000;
|
||||
using (var response = req.GetResponse())
|
||||
using (var stream = response.GetResponseStream())
|
||||
{
|
||||
var doc = System.Xml.Linq.XDocument.Load(stream);
|
||||
System.Xml.Linq.XNamespace asmv1 = "urn:schemas-microsoft-com:asm.v1";
|
||||
var identity = doc.Root?
|
||||
.Elements()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "assemblyIdentity" && e.Name.Namespace == asmv1);
|
||||
var versionAttr = identity?.Attribute("version")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(versionAttr)) return null;
|
||||
return Version.TryParse(versionAttr, out var v) ? v : null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanCheckForUpdate() => !IsCheckingForUpdate;
|
||||
|
||||
partial void OnIsCheckingForUpdateChanged(bool value)
|
||||
|
||||
@@ -36,11 +36,10 @@
|
||||
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl>
|
||||
<InstallUrl>https://gitea.dev.marcus-law.co.il/espocrm-extensions/OutlookAddin/raw/branch/main/Publish/</InstallUrl>
|
||||
<TargetCulture>en</TargetCulture>
|
||||
<ApplicationVersion>1.2.0.0</ApplicationVersion>
|
||||
<ApplicationVersion>1.2.10.0</ApplicationVersion>
|
||||
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
|
||||
<UpdateEnabled>true</UpdateEnabled>
|
||||
<UpdateInterval>7</UpdateInterval>
|
||||
<UpdateIntervalUnits>days</UpdateIntervalUnits>
|
||||
<UpdatePeriodically>false</UpdatePeriodically>
|
||||
<ProductName>MarcusLaw.OutlookAddin</ProductName>
|
||||
<PublisherName />
|
||||
<SupportUrl />
|
||||
@@ -258,6 +257,9 @@
|
||||
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
|
||||
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Ribbon\ReadMailRibbon.xml">
|
||||
<LogicalName>OutlookAddin.Ribbon.ReadMailRibbon.xml</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Include="Images\klear-file.png">
|
||||
<LogicalName>OutlookAddin.Images.klear-file.png</LogicalName>
|
||||
</EmbeddedResource>
|
||||
|
||||
@@ -33,6 +33,6 @@ using System.Security;
|
||||
// You can specify all the values or you can default the Build and Revision Numbers
|
||||
// by using the '*' as shown below:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.2.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.2.0.0")]
|
||||
[assembly: AssemblyVersion("1.2.14.0")]
|
||||
[assembly: AssemblyFileVersion("1.2.14.0")]
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace OutlookAddin.Ribbon
|
||||
{
|
||||
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
|
||||
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
|
||||
private const string ReadMailResource = "OutlookAddin.Ribbon.ReadMailRibbon.xml";
|
||||
|
||||
// Every button whose `getEnabled` depends on the current Explorer
|
||||
// selection must be re-evaluated on every SelectionChange — Office
|
||||
@@ -40,6 +41,8 @@ namespace OutlookAddin.Ribbon
|
||||
return LoadResource(ExplorerResource);
|
||||
case "Microsoft.Outlook.Mail.Compose":
|
||||
return LoadResource(InspectorResource);
|
||||
case "Microsoft.Outlook.Mail.Read":
|
||||
return LoadResource(ReadMailResource);
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
@@ -77,6 +80,51 @@ namespace OutlookAddin.Ribbon
|
||||
_inspectorRibbon = ribbon;
|
||||
}
|
||||
|
||||
// The read-mail inspector ribbon loads fresh for each opened message,
|
||||
// so its getEnabled callback re-runs per window — no cached IRibbonUI
|
||||
// or invalidation is needed here.
|
||||
public void OnReadMailRibbonLoad(IRibbonUI ribbon)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enabled when the message open in this read inspector has at least
|
||||
/// one attachment. Mirrors <see cref="OnGetSaveAttachmentsEnabled"/>
|
||||
/// but reads the item from the Inspector context rather than the
|
||||
/// Explorer selection.
|
||||
/// </summary>
|
||||
public bool OnGetSaveAttachmentsInspectorEnabled(IRibbonControl control)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inspector = control?.Context as Outlook.Inspector;
|
||||
if (inspector?.CurrentItem is not Outlook.MailItem mail) return false;
|
||||
var attachments = mail.Attachments;
|
||||
if (attachments == null) return false;
|
||||
return attachments.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSaveAttachmentsFromInspector(IRibbonControl control)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inspector = control?.Context as Outlook.Inspector;
|
||||
if (inspector?.CurrentItem is Outlook.MailItem mail)
|
||||
{
|
||||
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachmentsFromInspector failed");
|
||||
}
|
||||
}
|
||||
|
||||
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
|
||||
{
|
||||
try
|
||||
@@ -206,6 +254,7 @@ namespace OutlookAddin.Ribbon
|
||||
{
|
||||
case "FileToEspoCrm": fileName = "klear-file.png"; break;
|
||||
case "SaveAttachments": fileName = "klear-attach.png"; break;
|
||||
case "SaveAttachmentsRead": fileName = "klear-attach.png"; break;
|
||||
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
|
||||
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
|
||||
case "ComposeFromCase": fileName = "klear-compose.png"; break;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="OnReadMailRibbonLoad">
|
||||
<ribbon>
|
||||
<tabs>
|
||||
<tab idMso="TabReadMessage">
|
||||
<group id="MarcusLawReadGroup" label="Klear">
|
||||
<button id="SaveAttachmentsRead"
|
||||
label="שמור קבצים"
|
||||
screentip="שמור את הקבצים המצורפים בלבד"
|
||||
supertip="מעלה את הקבצים המצורפים של המייל הזה ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
|
||||
size="large"
|
||||
getImage="OnGetImage"
|
||||
onAction="OnSaveAttachmentsFromInspector"
|
||||
getEnabled="OnGetSaveAttachmentsInspectorEnabled" />
|
||||
</group>
|
||||
</tab>
|
||||
</tabs>
|
||||
</ribbon>
|
||||
</customUI>
|
||||
@@ -485,6 +485,13 @@ namespace OutlookAddin.Services
|
||||
// Subfolders are populated lazily once the user picks a case;
|
||||
// the default static set is already pre-loaded.
|
||||
|
||||
// If the sender already maps to a known client, seed the case
|
||||
// list so the user sees the identified case pre-selected instead
|
||||
// of having to search. Uses the same matching lookup the sidebar
|
||||
// does (cached for 10 min), so this is normally instant. Any
|
||||
// failure here is non-fatal — the dialog still opens empty.
|
||||
await TryPreselectSenderCaseAsync(vm, envelope.From).ConfigureAwait(true);
|
||||
|
||||
var dialog = new SaveAttachmentsDialog(vm);
|
||||
AttachOwnerToOutlook(dialog);
|
||||
var ok = dialog.ShowDialog();
|
||||
@@ -541,6 +548,30 @@ namespace OutlookAddin.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
|
||||
{
|
||||
string message;
|
||||
|
||||
@@ -42,21 +42,7 @@ namespace OutlookAddin.Services
|
||||
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
|
||||
|
||||
var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
|
||||
ContactEntity? primaryContact = null;
|
||||
if (caseEntity.ContactsIds != null && caseEntity.ContactsIds.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
primaryContact = await _client.GetContactAsync(
|
||||
caseEntity.ContactsIds[0],
|
||||
"id,name,emailAddress",
|
||||
default).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "ComposeService: failed to load primary contact for case {CaseId}", caseId);
|
||||
}
|
||||
}
|
||||
var recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
|
||||
|
||||
var guid = Guid.NewGuid().ToString("N");
|
||||
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
|
||||
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress))
|
||||
if (!string.IsNullOrWhiteSpace(recipientEmail))
|
||||
{
|
||||
var existingTo = mail.To ?? string.Empty;
|
||||
if (!existingTo.Contains(primaryContact!.EmailAddress!))
|
||||
if (!existingTo.Contains(recipientEmail!))
|
||||
{
|
||||
mail.To = string.IsNullOrEmpty(existingTo)
|
||||
? primaryContact.EmailAddress
|
||||
: existingTo + "; " + primaryContact.EmailAddress;
|
||||
? recipientEmail
|
||||
: existingTo + "; " + recipientEmail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
|
||||
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a usable recipient email for the case. EspoCRM's Case.contactsIds
|
||||
/// is a many-to-many bag (primary + co-counsel + witnesses + …) and the
|
||||
/// first entry isn't guaranteed to be the actual client — and even when
|
||||
/// it is, the contact may have no emailAddress stored. So walk the list
|
||||
/// and return the first non-empty address, then fall back to the
|
||||
/// linked Account's email.
|
||||
/// </summary>
|
||||
private async Task<string?> ResolveRecipientEmailAsync(CaseEntity caseEntity, string caseId)
|
||||
{
|
||||
if (caseEntity.ContactsIds != null)
|
||||
{
|
||||
foreach (var contactId in caseEntity.ContactsIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contactId)) continue;
|
||||
try
|
||||
{
|
||||
var contact = await _client.GetContactAsync(
|
||||
contactId,
|
||||
"id,name,emailAddress,emailAddressData",
|
||||
default).ConfigureAwait(true);
|
||||
if (!string.IsNullOrWhiteSpace(contact?.EmailAddress))
|
||||
{
|
||||
return contact!.EmailAddress;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "ComposeService: failed to load contact {ContactId} for case {CaseId}", contactId, caseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(caseEntity.AccountId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var account = await _client.GetAccountAsync(
|
||||
caseEntity.AccountId!,
|
||||
"id,name,emailAddress",
|
||||
default).ConfigureAwait(true);
|
||||
if (!string.IsNullOrWhiteSpace(account?.EmailAddress))
|
||||
{
|
||||
return account!.EmailAddress;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "ComposeService: failed to load account {AccountId} for case {CaseId}", caseEntity.AccountId, caseId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
|
||||
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
|
||||
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);
|
||||
|
||||
Reference in New Issue
Block a user