feat: in-app updater button + correct case-folder resolution

Two related improvements:

1. About tab → "בדוק עדכונים" button. Calls ClickOnce's
   ApplicationDeployment.CurrentDeployment.CheckForDetailedUpdate +
   Update on background threads, so the user can pull a new release
   immediately without leaving Outlook — no PowerShell, no
   CleanOnlineAppCache dance.

   Status messages cover the four real outcomes:
     • not deployed via ClickOnce (F5 / dev install) → explicit error
     • on the latest version → green "already up to date"
     • update available → progress text, then "installed — restart
       Outlook"
     • download / manifest / generic exception → red, with reason

2. Case folder resolution now matches the server. The previous client
   logic used Case.name (the lawsuit title, e.g. "מונאס אריאל נ' ביטוח
   לאומי") which produced "47-מונאס אריאל נ ביטוח לאומי". But
   NetworkStorageIntegration's buildEntityFolderName uses the primary
   contact's name from Case.contactsIds[0] → server actually creates
   "47-אריאל מונאס" for the same case, so the two diverged and our
   uploads went to a phantom folder.

   New flow on case selection:
     a) EnsureCaseSubfoldersAsync (creates server's canonical folders)
     b) GetCase with select=contactsIds + networkStorageFolderPath
     c) GetContactAsync on contactsIds[0] → grab Name
     d) ResolveCaseFolderPath(case, contactName) → exact match
     e) Cache result on vm.ResolvedCaseFolderPath; AddInHost reads it
        instead of recomputing.

   ResolveCaseFolderPath fallback order is now (1) the integration's
   stored path, (2) <number>-<primary contact name>, (3) <number>-<case
   name>, (4) the contact / case name alone — matching the server's
   buildEntityFolderName logic step by step.

Tests: 39 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-12 23:08:20 +03:00
parent 1788b5bbee
commit be5edc203c
4 changed files with 137 additions and 20 deletions
@@ -100,7 +100,18 @@
<StackPanel Margin="16"> <StackPanel Margin="16">
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" /> <TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
<TextBlock Text="תוסף Klear ל-Microsoft Outlook" Margin="0,4,0,12" /> <TextBlock Text="תוסף Klear ל-Microsoft Outlook" Margin="0,4,0,12" />
<TextBlock Text="{Binding VersionLine}" Foreground="Gray" /> <DockPanel LastChildFill="False" Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="{Binding VersionLine}" Foreground="Gray" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right"
Command="{Binding CheckForUpdateCommand}"
Content="בדוק עדכונים"
Padding="14,4"
MinWidth="120" />
</DockPanel>
<TextBlock Text="{Binding UpdateStatus}"
Foreground="{Binding UpdateStatusBrush}"
TextWrapping="Wrap"
Margin="0,4,0,0" />
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap" <TextBlock Margin="0,16,0,0" TextWrapping="Wrap"
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" /> Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
<TextBlock TextWrapping="Wrap" <TextBlock TextWrapping="Wrap"
@@ -45,6 +45,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
[ObservableProperty] [ObservableProperty]
private string? statusMessage; private string? statusMessage;
/// <summary>
/// Resolved server-side case folder path for the currently
/// selected case (e.g. "47-אריאל מונאס"). Populated by
/// <see cref="RefreshSubfoldersForSelectedCaseAsync"/> after the
/// case + primary contact have been fetched. Empty until then.
/// </summary>
public string? ResolvedCaseFolderPath { get; private set; }
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>(); public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>(); public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
@@ -120,33 +128,48 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// <summary> /// <summary>
/// Refreshes the subfolder dropdown to merge static defaults with /// Refreshes the subfolder dropdown to merge static defaults with
/// the actual folders that already exist on the network share for /// the actual folders that already exist on the network share for
/// the chosen case. Triggers when the case selection changes. /// the chosen case. Also resolves <see cref="ResolvedCaseFolderPath"/>
/// using the same logic as the server (primary contact's name, not
/// the case's lawsuit title).
/// </summary> /// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync() public async Task RefreshSubfoldersForSelectedCaseAsync()
{ {
ResetSubfolders(); ResetSubfolders();
ResolvedCaseFolderPath = null;
var c = SelectedCase; var c = SelectedCase;
if (c == null || string.IsNullOrEmpty(c.Id)) return; if (c == null || string.IsNullOrEmpty(c.Id)) return;
// Best-effort: ensure standard subfolders physically exist on
// disk before the user picks one.
try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); } try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); } catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
// Fetch the actual case folder path from the server (the string? primaryContactName = null;
// integration's hook sets it on Case.networkStorageFolderPath
// after the first document save).
try try
{ {
var detail = await _client.GetCaseAsync( var detail = await _client.GetCaseAsync(
c.Id, "id,name,number,networkStorageFolderPath", default).ConfigureAwait(true); c.Id, "id,name,number,contactsIds,networkStorageFolderPath", default).ConfigureAwait(true);
c.Number = detail.Number; c.Number = detail.Number;
c.Name = detail.Name; c.Name = detail.Name;
c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath; c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath;
c.ContactsIds = detail.ContactsIds ?? new List<string>();
if (c.ContactsIds.Count > 0)
{
try
{
var contact = await _client.GetContactAsync(c.ContactsIds[0], "id,name", default).ConfigureAwait(true);
primaryContactName = contact?.Name;
}
catch (Exception ex) { _logger.Warning(ex, "Primary contact fetch failed for case {CaseId}", c.Id); }
}
} }
catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); } catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); }
var folderPath = ResolveCaseFolderPath(c); var folderPath = ResolveCaseFolderPath(c, primaryContactName);
ResolvedCaseFolderPath = folderPath;
_logger.Information(
"Resolved case folder for {CaseId} (number={Number}, contact={Contact}, stored={Stored}) → {Path}",
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
if (string.IsNullOrEmpty(folderPath)) return; if (string.IsNullOrEmpty(folderPath)) return;
try try
@@ -155,7 +178,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
foreach (var item in items) foreach (var item in items)
{ {
if (!item.IsFolder) continue; if (!item.IsFolder) continue;
if (Subfolders.Contains(item.Name)) continue; // already in defaults if (Subfolders.Contains(item.Name)) continue;
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue; if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
Subfolders.Add(item.Name); Subfolders.Add(item.Name);
} }
@@ -166,19 +189,30 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
} }
} }
public static string ResolveCaseFolderPath(CaseEntity c) /// <summary>
/// Mirrors NetworkDocumentService::buildEntityFolderName for Case
/// on the server side. Fallback order:
/// 1) Case.networkStorageFolderPath (if the integration's hook
/// has set it — most authoritative)
/// 2) "&lt;number&gt;-&lt;primary contact name&gt;" ← server's recipe
/// 3) "&lt;number&gt;-&lt;case lawsuit name&gt;"
/// 4) contact / case name on its own
/// </summary>
public static string ResolveCaseFolderPath(CaseEntity c, string? primaryContactName = null)
{ {
if (c == null) return string.Empty; if (c == null) return string.Empty;
if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath)) if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath))
{ {
return c.NetworkStorageFolderPath!.TrimEnd('/'); return c.NetworkStorageFolderPath!.TrimEnd('/');
} }
// Best-effort match to NetworkDocumentService::buildEntityFolderName:
// "<number>-<primary contact name or case name>", sanitized. var nameForFolder = !string.IsNullOrWhiteSpace(primaryContactName)
var contactName = c.Name ?? string.Empty; ? primaryContactName!
: (c.Name ?? string.Empty);
var raw = !string.IsNullOrWhiteSpace(c.Number) var raw = !string.IsNullOrWhiteSpace(c.Number)
? $"{c.Number}-{contactName}" ? $"{c.Number}-{nameForFolder}"
: contactName; : nameForFolder;
return SanitizeFolderSegment(raw); return SanitizeFolderSegment(raw);
} }
@@ -225,9 +259,6 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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)) continue;
// Bare id+name in the list; the full Case (with number +
// networkStorageFolderPath) is hydrated when the user
// picks one.
Cases.Add(new CaseEntity Cases.Add(new CaseEntity
{ {
Id = hit.Id, Id = hit.Id,
@@ -40,6 +40,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
[ObservableProperty] [ObservableProperty]
private Brush statusBrush = NeutralBrush; private Brush statusBrush = NeutralBrush;
[ObservableProperty]
private string? updateStatus;
[ObservableProperty]
private Brush updateStatusBrush = NeutralBrush;
[ObservableProperty]
private bool isCheckingForUpdate;
public string InitialApiKey { get; } public string InitialApiKey { get; }
public string VersionLine { get; } public string VersionLine { get; }
@@ -129,6 +138,66 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private static string WatchedKey(string storeId, string path) => storeId + "|" + path; private static string WatchedKey(string storeId, string path) => storeId + "|" + path;
[RelayCommand(CanExecute = nameof(CanCheckForUpdate))]
private async Task CheckForUpdateAsync()
{
IsCheckingForUpdate = true;
UpdateStatusBrush = NeutralBrush;
UpdateStatus = "בודק עדכונים…";
try
{
if (!System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "התוסף לא מותקן דרך ClickOnce — אין כאן עדכון אוטומטי.";
return;
}
var deployment = System.Deployment.Application.ApplicationDeployment.CurrentDeployment;
var info = await Task.Run(() => deployment.CheckForDetailedUpdate(false)).ConfigureAwait(true);
if (!info.UpdateAvailable)
{
UpdateStatusBrush = SuccessBrush;
UpdateStatus = "אתה על הגרסה האחרונה (" + deployment.CurrentVersion + ").";
return;
}
UpdateStatus = $"זמינה גרסה {info.AvailableVersion} — מוריד…";
var result = await Task.Run(() => deployment.Update()).ConfigureAwait(true);
UpdateStatusBrush = SuccessBrush;
UpdateStatus = $"הותקנה גרסה {info.AvailableVersion}. סגור ופתח את Outlook כדי לטעון אותה.";
}
catch (System.Deployment.Application.DeploymentDownloadException ex)
{
_logger.Warning(ex, "CheckForUpdate: download failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "הורדת העדכון נכשלה: " + ex.Message;
}
catch (System.Deployment.Application.InvalidDeploymentException ex)
{
_logger.Warning(ex, "CheckForUpdate: invalid deployment manifest");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "ה-deployment manifest על השרת לא תקין: " + ex.Message;
}
catch (Exception ex)
{
_logger.Warning(ex, "CheckForUpdate failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "שגיאה בבדיקת עדכונים: " + ex.Message;
}
finally
{
IsCheckingForUpdate = false;
}
}
private bool CanCheckForUpdate() => !IsCheckingForUpdate;
partial void OnIsCheckingForUpdateChanged(bool value)
{
CheckForUpdateCommand.NotifyCanExecuteChanged();
}
[RelayCommand] [RelayCommand]
private async Task TestConnectionAsync() private async Task TestConnectionAsync()
{ {
+7 -1
View File
@@ -494,7 +494,13 @@ namespace OutlookAddin.Services
var selected = vm.SelectedAttachments; var selected = vm.SelectedAttachments;
if (picked == null || string.IsNullOrEmpty(picked.Id) || selected.Count == 0) return; if (picked == null || string.IsNullOrEmpty(picked.Id) || selected.Count == 0) return;
var caseFolder = SaveAttachmentsViewModel.ResolveCaseFolderPath(picked); // Prefer the path the VM resolved when the user picked the case
// (it already fetched the primary contact and applied the
// server's naming convention). Fall back to the no-contact
// resolve only if that step somehow didn't run.
var caseFolder = !string.IsNullOrWhiteSpace(vm.ResolvedCaseFolderPath)
? vm.ResolvedCaseFolderPath!
: SaveAttachmentsViewModel.ResolveCaseFolderPath(picked);
if (string.IsNullOrEmpty(caseFolder)) if (string.IsNullOrEmpty(caseFolder))
{ {
MessageBox.Show( MessageBox.Show(