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:
@@ -100,7 +100,18 @@
|
||||
<StackPanel Margin="16">
|
||||
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
|
||||
<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"
|
||||
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
|
||||
@@ -45,6 +45,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
[ObservableProperty]
|
||||
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<string> Subfolders { get; } = new ObservableCollection<string>();
|
||||
@@ -120,33 +128,48 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
/// <summary>
|
||||
/// Refreshes the subfolder dropdown to merge static defaults with
|
||||
/// 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>
|
||||
public async Task RefreshSubfoldersForSelectedCaseAsync()
|
||||
{
|
||||
ResetSubfolders();
|
||||
ResolvedCaseFolderPath = null;
|
||||
|
||||
var c = SelectedCase;
|
||||
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); }
|
||||
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
|
||||
|
||||
// Fetch the actual case folder path from the server (the
|
||||
// integration's hook sets it on Case.networkStorageFolderPath
|
||||
// after the first document save).
|
||||
string? primaryContactName = null;
|
||||
try
|
||||
{
|
||||
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.Name = detail.Name;
|
||||
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"); }
|
||||
|
||||
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;
|
||||
|
||||
try
|
||||
@@ -155,7 +178,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
foreach (var item in items)
|
||||
{
|
||||
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;
|
||||
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) "<number>-<primary contact name>" ← server's recipe
|
||||
/// 3) "<number>-<case lawsuit name>"
|
||||
/// 4) contact / case name on its own
|
||||
/// </summary>
|
||||
public static string ResolveCaseFolderPath(CaseEntity c, string? primaryContactName = null)
|
||||
{
|
||||
if (c == null) return string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath))
|
||||
{
|
||||
return c.NetworkStorageFolderPath!.TrimEnd('/');
|
||||
}
|
||||
// Best-effort match to NetworkDocumentService::buildEntityFolderName:
|
||||
// "<number>-<primary contact name or case name>", sanitized.
|
||||
var contactName = c.Name ?? string.Empty;
|
||||
|
||||
var nameForFolder = !string.IsNullOrWhiteSpace(primaryContactName)
|
||||
? primaryContactName!
|
||||
: (c.Name ?? string.Empty);
|
||||
|
||||
var raw = !string.IsNullOrWhiteSpace(c.Number)
|
||||
? $"{c.Number}-{contactName}"
|
||||
: contactName;
|
||||
? $"{c.Number}-{nameForFolder}"
|
||||
: nameForFolder;
|
||||
return SanitizeFolderSegment(raw);
|
||||
}
|
||||
|
||||
@@ -225,9 +259,6 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
foreach (var hit in search.List)
|
||||
{
|
||||
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
|
||||
{
|
||||
Id = hit.Id,
|
||||
|
||||
@@ -40,6 +40,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
[ObservableProperty]
|
||||
private Brush statusBrush = NeutralBrush;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? updateStatus;
|
||||
|
||||
[ObservableProperty]
|
||||
private Brush updateStatusBrush = NeutralBrush;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCheckingForUpdate;
|
||||
|
||||
public string InitialApiKey { get; }
|
||||
|
||||
public string VersionLine { get; }
|
||||
@@ -129,6 +138,66 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||
|
||||
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]
|
||||
private async Task TestConnectionAsync()
|
||||
{
|
||||
|
||||
@@ -494,7 +494,13 @@ namespace OutlookAddin.Services
|
||||
var selected = vm.SelectedAttachments;
|
||||
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))
|
||||
{
|
||||
MessageBox.Show(
|
||||
|
||||
Reference in New Issue
Block a user