Compare commits

..

3 Commits

Author SHA1 Message Date
PointStar be5edc203c 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>
2026-05-12 23:08:20 +03:00
PointStar 1788b5bbee fix(about): show real ClickOnce deployment version in Settings → About
The About tab was always showing "1.0.0" because it read the DLL's
AssemblyVersion, which we never bumped in source — even when the
ClickOnce manifest had been rolled to 1.1.0 by the CI tag.

Read System.Deployment.Application.ApplicationDeployment.CurrentDeployment
.CurrentVersion at runtime when the add-in is running under ClickOnce
(IsNetworkDeployed=true). That's the version the user actually
installed. Fall back to AssemblyVersion when dev-running under F5 (no
ClickOnce context). Adds System.Deployment GAC reference to the UI
project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:59:19 +03:00
PointStar 8011d4e34c diag(attachments): post-upload "did the file actually arrive" check
The server reported successful uploads for the two PDFs into case 6a03166d…
("מונאס אריאל") but the user couldn't find the files on the share. Both
LocalFilesystemClient::uploadFile and WebDavClient::uploadFile return
true only on a real write, so something downstream is silently dropping
the payload — most likely a WebDAV proxy or a basePath mismatch
between what NetworkStorageIntegration thinks the storage is vs. what
the user is browsing.

Add a verification step right after the upload:

  1. POST /Attachment   →  attachmentId
  2. POST /NetworkStorage/action/upload  →  201 from server
  3. GET  /NetworkStorage/folderContents?path=<folder>
     • If our filename is in the listing → INF "verify present"
     • Else → WRN "expected X but NOT in listing. Currently in folder: […]"

The diagnostic line surfaces both halves of the puzzle in a single
log entry, so the next save attempt will tell us in plain text whether
the storage layer is the liar or the path is being normalised
somewhere unexpected.

Tests: 39 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:12 +03:00
6 changed files with 219 additions and 22 deletions
@@ -59,6 +59,12 @@ namespace MarcusLaw.OutlookAddin.Core.Services
await _client.UploadAttachmentToNetworkStorageAsync(
uploaded.Id, trimmedFolder, cancellationToken).ConfigureAwait(false);
// Step 3: verify the file actually landed on the share —
// the server can return 200 yet leave nothing on disk
// when a downstream WebDAV proxy silently fails. Read
// the folder back and look for our filename.
await VerifyArrivedAsync(att.Name, trimmedFolder, cancellationToken).ConfigureAwait(false);
_logger.Information(
"Saved attachment {Name} → {Path} (attachmentId={AttachmentId})",
att.Name, trimmedFolder, uploaded.Id);
@@ -99,5 +105,54 @@ namespace MarcusLaw.OutlookAddin.Core.Services
return summary;
}
/// <summary>
/// Post-upload sanity check: list the folder we just wrote into
/// and confirm a file with the same (possibly sanitized) name
/// shows up. Diagnostic only — does not change the outcome. If
/// the file isn't there, log a WARNING with everything that IS
/// in the folder so we can spot the actual server-side path.
/// </summary>
private async Task VerifyArrivedAsync(string expectedName, string folderPath, CancellationToken ct)
{
try
{
var items = await _client.ListNetworkStorageFolderAsync(folderPath, ct).ConfigureAwait(false);
var trimmedExpected = expectedName.Trim().TrimStart('-', ' ').TrimEnd('-', ' ');
bool found = false;
foreach (var it in items)
{
if (it.IsFolder) continue;
if (string.Equals(it.Name, expectedName, StringComparison.Ordinal) ||
string.Equals(it.Name, trimmedExpected, StringComparison.Ordinal))
{
found = true;
break;
}
}
if (!found)
{
var names = new List<string>();
foreach (var it in items)
{
if (it.IsFolder) continue;
names.Add(it.Name);
}
_logger.Warning(
"Post-upload verify: expected '{Expected}' under '{Path}' but it's NOT in the listing. " +
"Server reported the upload as successful but the file is not on the share. " +
"Files currently in that folder: [{Files}]",
expectedName, folderPath, string.Join(", ", names));
}
else
{
_logger.Information("Post-upload verify: '{Expected}' present under '{Path}'", expectedName, folderPath);
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Post-upload verify failed for '{Expected}' under '{Path}'", expectedName, folderPath);
}
}
}
}
@@ -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"
@@ -18,6 +18,7 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Deployment" />
</ItemGroup>
<ItemGroup>
@@ -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) "&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 (!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; }
@@ -70,8 +79,32 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
Username = creds?.Username ?? string.Empty;
InitialApiKey = creds?.ApiKey ?? string.Empty;
var version = typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
VersionLine = $"גרסה {version}";
VersionLine = $"גרסה {ResolveDisplayVersion()}";
}
/// <summary>
/// Picks the most accurate version to surface in the About tab:
/// the ClickOnce activation version when the add-in is installed
/// over the wire (auto-updated to the real deployment version),
/// else the assembly's AssemblyVersion (which is set per-source
/// and may lag behind the deployed ClickOnce manifest).
/// </summary>
private static string ResolveDisplayVersion()
{
try
{
if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{
return System.Deployment.Application.ApplicationDeployment
.CurrentDeployment.CurrentVersion.ToString();
}
}
catch
{
// Not running under ClickOnce (dev / F5), or System.Deployment
// is not available — fall through to the AssemblyVersion.
}
return typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
}
/// <summary>
@@ -105,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()
{
+7 -1
View File
@@ -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(