Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be5edc203c | |||
| 1788b5bbee | |||
| 8011d4e34c |
@@ -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) "<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; }
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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