This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OutlookAddin/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs
T
PointStar 18b6640e57 feat(save-attachments): editable filenames, Hebrew status, smoother subfolder load
- DataGrid case picker now shows status via CaseStatusToHebrewConverter
  (added PendingHearing / PendingDecision mappings)
- Attachment row replaces the read-only filename TextBlock with a TwoWay
  TextBox so users can rename a file before saving (flows through
  EspoAttachment.Name into the upload + the on-disk path)
- SaveAttachmentsViewModel.IsLoadingSubfolders gates the folder ComboBox
  while EnsureCaseSubfolders / GetCase / ListNetworkStorageFolder run,
  so users can't pick a stale "(שורש התיק)" before the real list arrives
- About tab: ResolveDisplayVersion now reads from the host VSTO assembly
  (*.OutlookAddin) instead of the UI assembly. The UI csproj is SDK-style
  with no <Version> so it always reported 1.0.0.0, which leaked into the
  About line on dev installs
- Bump to 1.1.2.0 (csproj + AssemblyInfo) to match the v1.1.2 tag that
  will trigger the Gitea Actions auto-publish to platform.dev

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:27:49 +03:00

396 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Configuration;
using MarcusLaw.OutlookAddin.Core.Models;
using MarcusLaw.OutlookAddin.Core.Security;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
public sealed partial class SettingsViewModel : ObservableObject
{
private static readonly Brush NeutralBrush = Brushes.Gray;
private static readonly Brush ErrorBrush = Brushes.Firebrick;
private static readonly Brush SuccessBrush = Brushes.SeaGreen;
private readonly AppSettings _settings;
private readonly SettingsManager _settingsManager;
private readonly DpapiCredentialStore _credentialStore;
private readonly ILogger _logger;
[ObservableProperty]
private string espoCrmUrl = string.Empty;
[ObservableProperty]
private string username = string.Empty;
[ObservableProperty]
private string locale = "he-IL";
[ObservableProperty]
private string? statusMessage;
[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; }
public Func<string>? ApiKeyReader { get; set; }
public bool? DialogResult { get; private set; }
public event EventHandler? RequestClose;
public ObservableCollection<FolderRowViewModel> Folders { get; } = new ObservableCollection<FolderRowViewModel>();
public SettingsViewModel(
AppSettings settings,
SettingsManager settingsManager,
DpapiCredentialStore credentialStore,
ILogger logger)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_settingsManager = settingsManager ?? throw new ArgumentNullException(nameof(settingsManager));
_credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
EspoCrmUrl = settings.EspoCrmUrl;
Locale = string.IsNullOrWhiteSpace(settings.Locale) ? "he-IL" : settings.Locale;
var creds = _credentialStore.Load();
Username = creds?.Username ?? string.Empty;
InitialApiKey = creds?.ApiKey ?? string.Empty;
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()
{
Version? v = null;
try
{
if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{
v = System.Deployment.Application.ApplicationDeployment
.CurrentDeployment.CurrentVersion;
}
}
catch
{
// Not running under ClickOnce (dev / F5), or System.Deployment
// is not available — fall through to the AssemblyVersion.
}
// The host VSTO assembly is where versioning policy lives
// (csproj + AssemblyInfo). The UI assembly is SDK-style with
// no <Version>, so it always reports 1.0.0.0 — don't fall back
// to it. Walk loaded assemblies for the host by name suffix.
if (v == null)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var name = asm.GetName().Name;
if (name != null && name.EndsWith(".OutlookAddin", StringComparison.Ordinal))
{
v = asm.GetName().Version;
break;
}
}
}
v ??= typeof(SettingsViewModel).Assembly.GetName().Version;
if (v == null) return "1.0.0";
// ClickOnce stores 4 components; project policy is MAJOR.MINOR.PATCH —
// hide the trailing revision when it's 0.
return v.Revision == 0
? $"{v.Major}.{v.Minor}.{v.Build}"
: v.ToString();
}
/// <summary>
/// Populates the Folders tab. The host project owns the Outlook
/// session and must build the flat tree on the STA thread before
/// handing it here.
/// </summary>
public void LoadFolderTree(IReadOnlyList<FolderTreeNode> tree)
{
Folders.Clear();
if (tree == null) return;
var watchedLookup = new Dictionary<string, WatchedFolder>(StringComparer.Ordinal);
foreach (var w in _settings.WatchedFolders)
{
watchedLookup[WatchedKey(w.StoreId, w.FolderPath)] = w;
}
foreach (var node in tree)
{
var row = new FolderRowViewModel(node);
if (watchedLookup.TryGetValue(WatchedKey(node.StoreId, node.FolderPath), out var existing))
{
row.IsWatched = true;
row.Mode = existing.Mode;
row.ConfidenceThreshold = existing.ConfidenceThreshold;
}
Folders.Add(row);
}
}
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()
{
StatusBrush = NeutralBrush;
StatusMessage = "בודק חיבור…";
if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out var baseUrl))
{
Fail("כתובת שרת לא תקינה");
return;
}
var apiKey = ReadApiKey();
if (string.IsNullOrWhiteSpace(apiKey))
{
Fail("יש להזין מפתח API");
return;
}
HttpClient? http = null;
try
{
http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
var probe = new EspoCrmClient(http, baseUrl.ToString(), Username ?? string.Empty, apiKey, _logger);
var user = await probe.TestConnectionAsync().ConfigureAwait(true);
Succeed($"התחברות תקינה — משתמש: {user.UserName ?? user.Id}");
}
catch (EspoCrmAuthorizationException)
{
Fail("מפתח ה-API או שם המשתמש שגויים");
}
catch (Exception ex)
{
_logger.Warning(ex, "TestConnection failed for {Url}", baseUrl);
Fail("שגיאת חיבור: " + FormatExceptionForUser(ex, baseUrl));
}
finally
{
http?.Dispose();
}
}
/// <summary>
/// Walks the InnerException chain and pulls out the underlying socket
/// error code so the dialog actually tells the user something useful
/// (DNS miss, connection refused, TLS failure, etc.) instead of just
/// "One or more errors occurred".
/// </summary>
private static string FormatExceptionForUser(Exception ex, Uri target)
{
var current = ex;
while (current.InnerException != null)
{
current = current.InnerException;
}
if (current is System.Net.WebException web)
{
switch (web.Status)
{
case System.Net.WebExceptionStatus.SecureChannelFailure:
return "כשל TLS — לא הצלחתי ליצור ערוץ מאובטח. ייתכן שהשרת דורש TLS 1.2/1.3 שאינו מופעל ב-.NET Framework. נסה לסגור ולפתוח Outlook מחדש; אם הבעיה חוזרת, פנה לאדמין.";
case System.Net.WebExceptionStatus.TrustFailure:
return "אישור ה-TLS של השרת לא נסמך. ייתכן שצריך להתקין את ה-CA של השרת ב-Trusted Root.";
case System.Net.WebExceptionStatus.NameResolutionFailure:
return $"השרת {target.Host} לא נמצא ב-DNS. ודא שהכתובת נכונה.";
case System.Net.WebExceptionStatus.ConnectFailure:
return $"החיבור ל-{target.Host}:{target.Port} נכשל. ודא שהשרת רץ ושאין חסימה ברשת.";
case System.Net.WebExceptionStatus.Timeout:
return "החיבור פג זמן. השרת אולי לא מגיב.";
}
}
if (current is System.Net.Sockets.SocketException sock)
{
switch (sock.SocketErrorCode)
{
case System.Net.Sockets.SocketError.HostNotFound:
return $"השרת {target.Host} לא נמצא ב-DNS. ודא שהכתובת נכונה ושיש חיבור לאינטרנט.";
case System.Net.Sockets.SocketError.ConnectionRefused:
return $"החיבור ל-{target.Host}:{target.Port} נדחה. ודא שהשרת רץ ומאזין.";
case System.Net.Sockets.SocketError.TimedOut:
return $"חיבור ל-{target.Host} פג זמן. ייתכן שהשרת לא זמין או שיש חסימה ברשת.";
case System.Net.Sockets.SocketError.NetworkUnreachable:
case System.Net.Sockets.SocketError.HostUnreachable:
return "לא הצלחתי להגיע לרשת של השרת. בדוק את ה-VPN/חיבור הרשת.";
default:
return $"שגיאת רשת ({sock.SocketErrorCode}): {sock.Message}";
}
}
if (current is TaskCanceledException)
{
return "החיבור לא הגיב תוך 15 שניות. השרת אולי לא זמין או שהוא איטי.";
}
if (current is System.Security.Authentication.AuthenticationException)
{
return "אישור ה-TLS של השרת לא תקין. בדוק את הקישור (https vs http) או פנה לאדמין.";
}
// Fallback: outer + inner with newline so the user sees the chain.
var outer = ex.GetType().Name + ": " + ex.Message;
if (current != ex)
{
outer += " — " + current.GetType().Name + ": " + current.Message;
}
return outer;
}
[RelayCommand]
private void Save()
{
if (!Uri.TryCreate(EspoCrmUrl, UriKind.Absolute, out _))
{
Fail("כתובת שרת לא תקינה");
return;
}
var apiKey = ReadApiKey();
if (string.IsNullOrWhiteSpace(apiKey))
{
Fail("יש להזין מפתח API");
return;
}
try
{
_settings.EspoCrmUrl = EspoCrmUrl.TrimEnd('/');
_settings.Locale = string.IsNullOrWhiteSpace(Locale) ? "he-IL" : Locale;
_settings.WatchedFolders.Clear();
foreach (var row in Folders)
{
if (row.IsWatched) _settings.WatchedFolders.Add(row.ToWatchedFolder());
}
_settingsManager.Save(_settings);
_credentialStore.Save(string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(), apiKey);
_logger.Information("Settings saved (user={User}, url={Url}, watched={WatchedCount})",
Username, _settings.EspoCrmUrl, _settings.WatchedFolders.Count);
DialogResult = true;
RequestClose?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.Error(ex, "Settings save failed");
Fail("שמירת ההגדרות נכשלה: " + ex.Message);
}
}
[RelayCommand]
private void Cancel()
{
DialogResult = false;
RequestClose?.Invoke(this, EventArgs.Empty);
}
private string ReadApiKey() => ApiKeyReader?.Invoke() ?? string.Empty;
private void Fail(string message)
{
StatusBrush = ErrorBrush;
StatusMessage = message;
}
private void Succeed(string message)
{
StatusBrush = SuccessBrush;
StatusMessage = message;
}
}
}