Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 137929b4ac | |||
| 95d7599cb1 | |||
| 62254fcd8c | |||
| 7aa96c99a3 | |||
| 35455c2ef6 | |||
| 025dab9b20 | |||
| b1dc57324e | |||
| 18b6640e57 | |||
| dbd7f3068b | |||
| b6624a924f | |||
| e3fb0e95d1 | |||
| 9e7a64045f | |||
| 017f10e60b | |||
| be5edc203c | |||
| 1788b5bbee | |||
| 8011d4e34c | |||
| 5242abb585 | |||
| 037d573e6e | |||
| cb7d530b4c | |||
| 7924cda520 | |||
| 7ee9a00fc4 | |||
| af8cf13443 | |||
| 26322c92ff | |||
| c98fee7b5b | |||
| 1a9ca2d2ad | |||
| b1f8e3e50b | |||
| 0fea393b81 |
@@ -0,0 +1,15 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
# ClickOnce publish output must be byte-identical to the hashes recorded
|
||||||
|
# in dll.manifest at publish time. Treat everything under Publish/ as
|
||||||
|
# binary so git's CRLF/LF normalization can't change the bytes between
|
||||||
|
# what VS published and what end-users download via setup.exe.
|
||||||
|
Publish/** -text
|
||||||
|
|
||||||
|
# Defense in depth — apply the same rule to ClickOnce extensions
|
||||||
|
# regardless of where they live.
|
||||||
|
*.deploy binary
|
||||||
|
*.manifest binary
|
||||||
|
*.vsto binary
|
||||||
|
*.application binary
|
||||||
|
*.pfx binary
|
||||||
+8
-2
@@ -24,16 +24,22 @@ project.lock.json
|
|||||||
project.fragment.lock.json
|
project.fragment.lock.json
|
||||||
|
|
||||||
# Publish output (ClickOnce / MSIX)
|
# Publish output (ClickOnce / MSIX)
|
||||||
publish/
|
# NOTE: Publish/ itself is COMMITTED so end-users can fetch the ClickOnce
|
||||||
|
# bundle via Gitea raw URLs (https://gitea.dev.marcus-law.co.il/.../raw/
|
||||||
|
# branch/main/Publish/). VS dumps build output there on every Publish.
|
||||||
PublishProfiles/
|
PublishProfiles/
|
||||||
*.pubxml.user
|
*.pubxml.user
|
||||||
|
|
||||||
# VSTO build output
|
# VSTO build output — these wildcards exclude intermediate bin/obj
|
||||||
|
# artifacts. Re-include everything under Publish/ which is the
|
||||||
|
# ClickOnce distribution root (must contain .vsto/.application/.deploy
|
||||||
|
# /.manifest files for the installer to work).
|
||||||
*_TemporaryKey.pfx
|
*_TemporaryKey.pfx
|
||||||
*.vsto
|
*.vsto
|
||||||
*.application
|
*.application
|
||||||
*.deploy
|
*.deploy
|
||||||
*.manifest
|
*.manifest
|
||||||
|
!Publish/**
|
||||||
|
|
||||||
# Code signing — NEVER commit private keys
|
# Code signing — NEVER commit private keys
|
||||||
*.pfx
|
*.pfx
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -268,14 +268,47 @@ If the session is on the Linux dev server (`/home/chaim/espocrm-extensions/Outlo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## How releases work (current, 2026-05-24)
|
||||||
|
|
||||||
|
**Production install URL:** `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`
|
||||||
|
|
||||||
|
**Release flow:**
|
||||||
|
|
||||||
|
1. Code change merged to `main` (push or PR-merge — no CI fires yet)
|
||||||
|
2. Bump `<ApplicationVersion>` in `src/OutlookAddin/OutlookAddin.csproj` and `AssemblyVersion` + `AssemblyFileVersion` in `src/OutlookAddin/Properties/AssemblyInfo.cs` to the next 4-part version (e.g. `1.2.1.0`). CI overrides `ApplicationVersion` from the tag at build time, but leaves AssemblyInfo alone — keep them in sync manually so the bundled DLL's FileVersion matches the manifest.
|
||||||
|
3. `git tag -a v1.2.1 -m "..." && git push --tags`
|
||||||
|
4. Gitea Actions workflow (`.gitea/workflows/build.yml`) fires:
|
||||||
|
- Imports the **real** codesign cert from secrets `CODESIGN_CERT_PFX_BASE64` + `CODESIGN_THUMBPRINT` (publicKeyToken `c68d2b4c25051c5b`, `CN=Marcus-Law OutlookAddin`)
|
||||||
|
- Patches csproj to swap the dev self-signed thumbprint (`2399E9BF…`) for the real one
|
||||||
|
- `msbuild /t:Publish` with `/p:InstallUrl=https://platform.dev.marcus-law.co.il/outlook-addin/` and `/p:ApplicationVersion=<tag>.0`
|
||||||
|
- Uploads `publish.tar.gz` to Gitea generic registry (`outlook-addin-publish/<version>/publish.tar.gz`)
|
||||||
|
- Linux job downloads the tar.gz, builds a Docker image `outlook-addin-cdn:latest`, pushes to Gitea registry, calls Coolify to redeploy the CDN container
|
||||||
|
- Posts to Mattermost channel `git-verelasim` with the install URL on success / a failure link on failure
|
||||||
|
5. Lawyers' installed Add-in checks the deployment manifest at the install URL every 7 days (configured in csproj `<UpdateInterval>7</UpdateInterval>`) or whenever they click "בדוק עדכונים" in Settings → אודות.
|
||||||
|
|
||||||
|
**Semver policy:** PATCH for bug fixes (unilateral), MINOR for visible features (unilateral when warranted), MAJOR only with Chaim's explicit approval.
|
||||||
|
|
||||||
|
**Branding (`KlearBranding` repo):** the product is presented to lawyers as **Klear** — title bars, MessageBox titles, ribbon group label, About header all say "Klear". The logo is mirrored from `espocrm-extensions/KlearBranding/files/client/custom/img/logo-klear.png` into `src/OutlookAddin.UI/Resources/logo-klear.png` and embedded as a WPF Resource. Brand accent color: `#121d2e` (dark navy).
|
||||||
|
|
||||||
|
**What stays MarcusLaw under the hood (deliberate — preserves auto-update across the rebrand):**
|
||||||
|
- ClickOnce app identity (`AssemblyName=MarcusLaw.OutlookAddin`)
|
||||||
|
- `%APPDATA%\MarcusLaw\OutlookAddin\settings.json` and `creds.dat`
|
||||||
|
- `%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs\`
|
||||||
|
- `DpapiCredentialStore.EntropySeed = "MarcusLaw.OutlookAddin.v1"`
|
||||||
|
- Code namespaces `MarcusLaw.OutlookAddin.*`
|
||||||
|
- Named pipe `MarcusLaw.OutlookAddin`
|
||||||
|
|
||||||
|
Changing any of these would orphan all existing installs and require a coordinated uninstall+reinstall on every lawyer's PC plus an entropy migration for DPAPI-encrypted credentials. See the LegalCRM rebrand misadventure in git history for what *not* to do.
|
||||||
|
|
||||||
|
**Known traps when releasing:**
|
||||||
|
- VS Publish wizard (Project Properties → Publish) **rewrites** csproj at every click — flips `<AutoIncrementApplicationRevision>` back to `true`, resets `<ApplicationVersion>` to whatever's in the UI, sometimes deletes prior `Application Files/<...>/` bundles. **Don't use it.** Tag and let CI do it.
|
||||||
|
- There's an old `Publish/` directory in git history that was a parallel manual-release channel via `gitea.../raw/branch/main/Publish/`. It's **not the production install URL** — that's platform.dev. The folder was dropped in v1.2.0.
|
||||||
|
|
||||||
## Things deliberately deferred
|
## Things deliberately deferred
|
||||||
|
|
||||||
| What | When | Why deferred |
|
| What | When | Why deferred |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Windows CI runner on Coolify | Until ClickOnce publish becomes useful | Local builds on dev's VS 2022 suffice through week 5 |
|
| Identity-level rebrand to a pure Klear product (AssemblyName, namespaces, DPAPI entropy, AppData paths) | Until there's another reason to force-reinstall on all 10 PCs | Visible rebrand already done in v1.2.0 without disrupting users; full rename trades aesthetic cleanliness for a day of IT coordination + lost credentials |
|
||||||
| Code signing cert (self-signed) + GPO push to 10 PCs | Week 6 (before release) | IT task; cert isn't useful until we have something to sign |
|
|
||||||
| ClickOnce hosting setup at `platform.dev.marcus-law.co.il/outlook-addin/` | Week 6 | No build artifacts yet |
|
|
||||||
| Mattermost release notification webhook in CI | Week 6 | Coupled to CI runner |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Response from POST /api/v1/Attachment — the server returns at least
|
||||||
|
/// the new attachment's id, which we then thread into a Document create.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AttachmentEntity
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string? Type { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
|
|||||||
[JsonPropertyName("status")]
|
[JsonPropertyName("status")]
|
||||||
public string? Status { get; set; }
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
// Marcus-Law custom field on Case — court file/docket number.
|
||||||
|
// Field name guessed from EspoCRM "c-prefixed PascalCase" convention;
|
||||||
|
// adjust if the admin used a different name.
|
||||||
|
[JsonPropertyName("cCourtCaseNumber")]
|
||||||
|
[JsonConverter(typeof(FlexibleStringConverter))]
|
||||||
|
public string? CourtCaseNumber { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("accountId")]
|
[JsonPropertyName("accountId")]
|
||||||
public string? AccountId { get; set; }
|
public string? AccountId { get; set; }
|
||||||
|
|
||||||
@@ -31,5 +38,12 @@ namespace MarcusLaw.OutlookAddin.Core.Models
|
|||||||
|
|
||||||
[JsonPropertyName("createdAt")]
|
[JsonPropertyName("createdAt")]
|
||||||
public DateTimeOffset? CreatedAt { get; set; }
|
public DateTimeOffset? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
// Set by Marcus-Law's NetworkStorageIntegration hook on first
|
||||||
|
// Document save. Empty until a document has been written through
|
||||||
|
// EspoCRM. When empty we compute it client-side from number +
|
||||||
|
// primary contact name.
|
||||||
|
[JsonPropertyName("networkStorageFolderPath")]
|
||||||
|
public string? NetworkStorageFolderPath { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||||
|
{
|
||||||
|
public sealed class DocumentEntity
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("fileId")]
|
||||||
|
public string? FileId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("folderId")]
|
||||||
|
public string? FolderId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("folderName")]
|
||||||
|
public string? FolderName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("parentType")]
|
||||||
|
public string? ParentType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("parentId")]
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A row from /api/v1/DocumentFolder — used to populate the folder
|
||||||
|
/// dropdown in the "Save attachments" dialog. EspoCRM document folders
|
||||||
|
/// are organizational labels independent of the document's parent link.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DocumentFolder
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("parentId")]
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("order")]
|
||||||
|
public int? Order { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
|
|||||||
|
|
||||||
public int CandidateCount { get; set; }
|
public int CandidateCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see cref="IsAmbiguous"/> is true, holds the candidate
|
||||||
|
/// contacts that share the sender's email so the sidebar can show
|
||||||
|
/// a picker instead of bouncing the user into manual search.
|
||||||
|
/// </summary>
|
||||||
|
public List<ContactEntity> Candidates { get; set; } = new List<ContactEntity>();
|
||||||
|
|
||||||
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.Core.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One row from GET /NetworkStorage/folderContents — folder or file.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NetworkStorageItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("path")]
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = string.Empty; // "folder" or "file"
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public long Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("modified")]
|
||||||
|
public string? Modified { get; set; }
|
||||||
|
|
||||||
|
public bool IsFolder => string.Equals(Type, "folder", System.StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MarcusLaw.OutlookAddin.Core.Models;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.Core.Services
|
||||||
|
{
|
||||||
|
public sealed class AttachmentSaveService : IAttachmentSaveService
|
||||||
|
{
|
||||||
|
private readonly IEspoCrmClient _client;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public AttachmentSaveService(IEspoCrmClient client, ILogger logger)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AttachmentSaveSummary> SaveAsync(
|
||||||
|
IReadOnlyList<EspoAttachment> attachments,
|
||||||
|
string targetFolderPath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (attachments == null) throw new ArgumentNullException(nameof(attachments));
|
||||||
|
if (string.IsNullOrWhiteSpace(targetFolderPath)) throw new ArgumentException("targetFolderPath required", nameof(targetFolderPath));
|
||||||
|
|
||||||
|
var summary = new AttachmentSaveSummary();
|
||||||
|
if (attachments.Count == 0) return summary;
|
||||||
|
|
||||||
|
var trimmedFolder = targetFolderPath.TrimEnd('/');
|
||||||
|
|
||||||
|
foreach (var att in attachments)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (summary.AuthRequired)
|
||||||
|
{
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.AuthRequired,
|
||||||
|
ErrorMessage = "Skipped — earlier upload rejected by EspoCRM"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? attachmentId = null;
|
||||||
|
Exception? uploadException = null;
|
||||||
|
|
||||||
|
// Step 1: create the EspoCRM Attachment (binary stored in
|
||||||
|
// data/upload/<id>; not yet on the network share). Auth
|
||||||
|
// failures here are terminal — skip remaining items.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uploaded = await _client.UploadAttachmentAsync(
|
||||||
|
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
|
||||||
|
attachmentId = uploaded.Id;
|
||||||
|
}
|
||||||
|
catch (EspoCrmAuthorizationException ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Attachment save aborted: EspoCRM rejected credentials");
|
||||||
|
summary.AuthRequired = true;
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.AuthRequired,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// No attachment ID means we have nothing on the share —
|
||||||
|
// a hard fail.
|
||||||
|
_logger.Warning(ex, "Attachment upload (Step 1) failed for {Name}", att.Name);
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.Failed,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: ask the NetworkStorageIntegration to place the
|
||||||
|
// uploaded blob under <targetFolderPath>. The 15s Polly
|
||||||
|
// timeout sometimes fires while the server is still
|
||||||
|
// writing the file — capture the exception but don't
|
||||||
|
// decide the outcome yet.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _client.UploadAttachmentToNetworkStorageAsync(
|
||||||
|
attachmentId, trimmedFolder, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (EspoCrmAuthorizationException ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Attachment save aborted on Step 2: EspoCRM rejected credentials");
|
||||||
|
summary.AuthRequired = true;
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.AuthRequired,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
uploadException = ex;
|
||||||
|
_logger.Warning(ex,
|
||||||
|
"Attachment NetworkStorage upload (Step 2) reported failure for {Name}; will verify disk before declaring failure",
|
||||||
|
att.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: verify on disk. Authoritative — if the file is
|
||||||
|
// there, the save succeeded regardless of what Step 2
|
||||||
|
// reported (Polly timeouts during large writes are common).
|
||||||
|
var verified = await VerifyArrivedWithRetryAsync(
|
||||||
|
att.Name, trimmedFolder, uploadException != null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (verified)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Saved attachment {Name} → {Path} (attachmentId={AttachmentId}, uploadException={HadException})",
|
||||||
|
att.Name, trimmedFolder, attachmentId, uploadException != null);
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.Saved,
|
||||||
|
AttachmentId = attachmentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var message = uploadException?.Message
|
||||||
|
?? "Server reported success but the file is not on the share.";
|
||||||
|
_logger.Warning(
|
||||||
|
"Attachment save Failed for {Name}: not present after verify (uploadException={Msg})",
|
||||||
|
att.Name, message);
|
||||||
|
summary.Items.Add(new AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
FileName = att.Name,
|
||||||
|
Outcome = AttachmentSaveOutcome.Failed,
|
||||||
|
ErrorMessage = message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative post-upload check. Returns true if the file is on
|
||||||
|
/// disk under <paramref name="folderPath"/>. When the upload call
|
||||||
|
/// raised (typically Polly's 15s timeout on a still-writing server),
|
||||||
|
/// we retry the listing a few times so the server gets a chance
|
||||||
|
/// to finish.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> VerifyArrivedWithRetryAsync(string expectedName, string folderPath, bool afterFailure, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// When the upload appeared to succeed, one check is plenty — the
|
||||||
|
// file is already there. When it appeared to fail, the server
|
||||||
|
// may still be writing; give it a few extra seconds.
|
||||||
|
var attempts = afterFailure ? 4 : 1;
|
||||||
|
var delay = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
for (int i = 0; i < attempts; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (i > 0) await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (await IsPresentAsync(expectedName, folderPath, ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
_logger.Information(
|
||||||
|
"Post-upload verify: '{Expected}' appeared under '{Path}' on attempt {Attempt}/{Total} despite upload exception",
|
||||||
|
expectedName, folderPath, i + 1, attempts);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsPresentAsync(string expectedName, string folderPath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var items = await _client.ListNetworkStorageFolderAsync(folderPath, ct).ConfigureAwait(false);
|
||||||
|
var trimmedExpected = expectedName.Trim().TrimStart('-', ' ').TrimEnd('-', ' ');
|
||||||
|
foreach (var it in items)
|
||||||
|
{
|
||||||
|
if (it.IsFolder) continue;
|
||||||
|
if (string.Equals(it.Name, expectedName, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(it.Name, trimmedExpected, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Post-upload listing under '{Path}' failed", folderPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,320 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
|
||||||
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
|
||||||
|
|
||||||
|
public async Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName));
|
||||||
|
if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content));
|
||||||
|
|
||||||
|
var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||||
|
var dataUri = "data:" + mime + ";base64," + base64Content;
|
||||||
|
// Per the official EspoCRM API docs (POST /Attachment), the binary
|
||||||
|
// is sent in the "file" field as a data-URI. The previous extra
|
||||||
|
// "contents" alias was an undocumented legacy that strict tenants
|
||||||
|
// reject with 400.
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = fileName,
|
||||||
|
["type"] = mime,
|
||||||
|
["role"] = "Attachment",
|
||||||
|
["relatedType"] = "Document",
|
||||||
|
["field"] = "file",
|
||||||
|
["file"] = dataUri
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
_logger.Information(
|
||||||
|
"UploadAttachment POST /Attachment (name={Name}, type={Type}, base64Length={Length})",
|
||||||
|
fileName, mime, base64Content.Length);
|
||||||
|
|
||||||
|
var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var reason = string.Join(",", response.Headers.TryGetValues("X-Status-Reason", out var v) ? v : Array.Empty<string>());
|
||||||
|
_logger.Warning(
|
||||||
|
"UploadAttachment failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||||
|
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
||||||
|
// 401 only = auth. 403 here is content/permission, not creds.
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
throw new EspoCrmAuthorizationException(
|
||||||
|
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
||||||
|
}
|
||||||
|
throw new EspoCrmHttpException(response.StatusCode, errBody,
|
||||||
|
$"EspoCRM Attachment upload returned {(int)response.StatusCode} {response.StatusCode}" +
|
||||||
|
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
|
||||||
|
}
|
||||||
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
return JsonSerializer.Deserialize<AttachmentEntity>(body, JsonOptions)
|
||||||
|
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
|
||||||
|
if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId));
|
||||||
|
|
||||||
|
// Marcus-Law's EspoCRM has `publishDate` configured as required on
|
||||||
|
// Document. The field accepts a plain date (YYYY-MM-DD). Default
|
||||||
|
// to today; the user can edit it later in EspoCRM if needed.
|
||||||
|
var todayIso = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["name"] = name,
|
||||||
|
["fileId"] = attachmentFileId,
|
||||||
|
["status"] = "Active",
|
||||||
|
["publishDate"] = todayIso
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType;
|
||||||
|
if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId;
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
_logger.Information(
|
||||||
|
"CreateDocument POST /Document (name={Name}, fileId={FileId}, parent={ParentType}/{ParentId}, folder={FolderId})",
|
||||||
|
name, attachmentFileId, parentType, parentId, folderId ?? "(none)");
|
||||||
|
|
||||||
|
var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
||||||
|
? string.Join(",", v) : "(none)";
|
||||||
|
_logger.Warning(
|
||||||
|
"CreateDocument failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||||
|
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
throw new EspoCrmAuthorizationException(
|
||||||
|
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
||||||
|
}
|
||||||
|
throw new EspoCrmHttpException(response.StatusCode, errBody,
|
||||||
|
$"EspoCRM Document create returned {(int)response.StatusCode} {response.StatusCode}" +
|
||||||
|
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
|
||||||
|
}
|
||||||
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
return JsonSerializer.Deserialize<DocumentEntity>(body, JsonOptions)
|
||||||
|
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(caseId)) return new List<DocumentFolder>();
|
||||||
|
|
||||||
|
// Some EspoCRM installs reject filtering by polymorphic parentType
|
||||||
|
// with a 400. ParentId is a regular string field, and an ID alone
|
||||||
|
// is unique enough — we'll filter just by that. We additionally
|
||||||
|
// verify parentType client-side to be safe.
|
||||||
|
var path =
|
||||||
|
"Document?select=" + Uri.EscapeDataString("id,folderId,folderName,parentType") +
|
||||||
|
"&where[0][type]=equals" +
|
||||||
|
"&where[0][attribute]=parentId" +
|
||||||
|
"&where[0][value]=" + Uri.EscapeDataString(caseId) +
|
||||||
|
"&maxSize=200";
|
||||||
|
|
||||||
|
// Hand-rolled error capture (instead of GetJsonAsync) so a 400 from
|
||||||
|
// a picky tenant ends up in the log with body + X-Status-Reason.
|
||||||
|
HttpResponseMessage? response = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await ExecuteAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
||||||
|
? string.Join(",", v) : "(none)";
|
||||||
|
_logger.Warning(
|
||||||
|
"ListFoldersUsedInCase({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||||
|
caseId, response.StatusCode, reason,
|
||||||
|
string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
|
||||||
|
return new List<DocumentFolder>();
|
||||||
|
}
|
||||||
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var parsed = JsonSerializer.Deserialize<EspoListResponse<DocumentEntity>>(body, JsonOptions)
|
||||||
|
?? new EspoListResponse<DocumentEntity>();
|
||||||
|
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var folders = new List<DocumentFolder>();
|
||||||
|
foreach (var doc in parsed.List)
|
||||||
|
{
|
||||||
|
// Defensive: if the server returns docs from other parents
|
||||||
|
// (shouldn't, but ID collisions are theoretically possible),
|
||||||
|
// drop them.
|
||||||
|
if (!string.IsNullOrEmpty(doc.ParentType) &&
|
||||||
|
!string.Equals(doc.ParentType, "Case", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(doc.FolderId)) continue;
|
||||||
|
if (!seen.Add(doc.FolderId!)) continue;
|
||||||
|
folders.Add(new DocumentFolder
|
||||||
|
{
|
||||||
|
Id = doc.FolderId!,
|
||||||
|
Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_logger.Information(
|
||||||
|
"ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders",
|
||||||
|
caseId, parsed.List.Count, folders.Count);
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId);
|
||||||
|
return new List<DocumentFolder>();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["entityType"] = "Case",
|
||||||
|
["entityId"] = caseId
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/createSubfolders", json, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
|
||||||
|
_logger.Warning(
|
||||||
|
"EnsureCaseSubfolders({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||||
|
caseId, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
|
||||||
|
}
|
||||||
|
// Don't throw — folder creation is best-effort. Upload will fail
|
||||||
|
// with its own clear error if the parent really isn't there.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_logger.Information("EnsureCaseSubfolders({CaseId}) OK", caseId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = "NetworkStorage/folderContents";
|
||||||
|
if (!string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
query += "?path=" + Uri.EscapeDataString(path);
|
||||||
|
}
|
||||||
|
HttpResponseMessage? response = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await ExecuteAsync(HttpMethod.Get, query, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
|
||||||
|
_logger.Warning(
|
||||||
|
"ListNetworkStorageFolder({Path}): {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||||
|
path, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
|
||||||
|
return new List<NetworkStorageItem>();
|
||||||
|
}
|
||||||
|
var json = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var items = JsonSerializer.Deserialize<List<NetworkStorageItem>>(json, JsonOptions);
|
||||||
|
_logger.Information("ListNetworkStorageFolder({Path}) → {Count} entries", path, items?.Count ?? 0);
|
||||||
|
return items ?? new List<NetworkStorageItem>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "ListNetworkStorageFolderAsync({Path}) failed", path);
|
||||||
|
return new List<NetworkStorageItem>();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(attachmentId)) throw new ArgumentException("attachmentId required", nameof(attachmentId));
|
||||||
|
if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentException("targetPath required", nameof(targetPath));
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["attachmentId"] = attachmentId,
|
||||||
|
["path"] = targetPath
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||||
|
_logger.Information(
|
||||||
|
"UploadToNetworkStorage POST /NetworkStorage/action/upload (attachmentId={AttachmentId}, path={Path})",
|
||||||
|
attachmentId, targetPath);
|
||||||
|
|
||||||
|
var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/upload", json, cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
|
||||||
|
_logger.Warning(
|
||||||
|
"UploadToNetworkStorage failed: {Status} (X-Status-Reason={Reason}) body={Body}",
|
||||||
|
response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
|
||||||
|
}
|
||||||
|
throw new EspoCrmHttpException(response.StatusCode, body,
|
||||||
|
$"NetworkStorage upload returned {(int)response.StatusCode} {response.StatusCode}" +
|
||||||
|
(string.IsNullOrEmpty(reason) || reason == "(none)" ? "" : " (reason: " + reason + ")"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await GetJsonAsync<EspoListResponse<DocumentFolder>>(
|
||||||
|
"DocumentFolder?orderBy=name&order=asc&maxSize=200",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.Information(
|
||||||
|
"ListDocumentFoldersAsync → {Count} folders in tenant",
|
||||||
|
result?.List?.Count ?? 0);
|
||||||
|
return result ?? new EspoListResponse<DocumentFolder>();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list");
|
||||||
|
return new EspoListResponse<DocumentFolder>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
|
public async Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
|
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
|
||||||
@@ -343,14 +657,20 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
if (response.IsSuccessStatusCode) return;
|
if (response.IsSuccessStatusCode) return;
|
||||||
|
|
||||||
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
var body = await ReadBodyAsync(response).ConfigureAwait(false);
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
|
// Only 401 is unambiguously an authentication problem. 403 in
|
||||||
|
// EspoCRM is "you are who you say you are, but THIS operation
|
||||||
|
// isn't allowed" — content type rejected, ACL miss, required
|
||||||
|
// role missing, etc. Conflating them aborts whole batches.
|
||||||
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
throw new EspoCrmAuthorizationException(
|
throw new EspoCrmAuthorizationException(
|
||||||
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
|
||||||
|
? " (reason: " + string.Join(",", v) + ")" : string.Empty;
|
||||||
throw new EspoCrmHttpException(response.StatusCode, body,
|
throw new EspoCrmHttpException(response.StatusCode, body,
|
||||||
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}: {Truncate(body, 500)}");
|
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}{reason}: {Truncate(body, 500)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadBodyAsync(HttpResponseMessage response)
|
private static async Task<string> ReadBodyAsync(HttpResponseMessage response)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MarcusLaw.OutlookAddin.Core.Models;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.Core.Services
|
||||||
|
{
|
||||||
|
public interface IAttachmentSaveService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads each attachment as an EspoCRM Attachment, then asks
|
||||||
|
/// Marcus-Law's NetworkStorage integration to place the file under
|
||||||
|
/// <paramref name="targetFolderPath"/> on the shared storage. No
|
||||||
|
/// Document entity is created — these are raw files dropped into
|
||||||
|
/// the case's filesystem folder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="attachments">files to save</param>
|
||||||
|
/// <param name="targetFolderPath">
|
||||||
|
/// Full relative folder path on storage, e.g.
|
||||||
|
/// <c>"30-מרדכי שמביקו/אסמכתאות"</c>. The case folder + optional
|
||||||
|
/// subfolder. Filename is appended internally.
|
||||||
|
/// </param>
|
||||||
|
Task<AttachmentSaveSummary> SaveAsync(
|
||||||
|
IReadOnlyList<EspoAttachment> attachments,
|
||||||
|
string targetFolderPath,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AttachmentSaveSummary
|
||||||
|
{
|
||||||
|
public List<AttachmentSaveItemResult> Items { get; set; } = new List<AttachmentSaveItemResult>();
|
||||||
|
|
||||||
|
public bool AuthRequired { get; set; }
|
||||||
|
|
||||||
|
public int SavedCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var n = 0;
|
||||||
|
foreach (var it in Items)
|
||||||
|
{
|
||||||
|
if (it.Outcome == AttachmentSaveOutcome.Saved) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FailedCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var n = 0;
|
||||||
|
foreach (var it in Items)
|
||||||
|
{
|
||||||
|
if (it.Outcome == AttachmentSaveOutcome.Failed) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AttachmentSaveItemResult
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public AttachmentSaveOutcome Outcome { get; set; }
|
||||||
|
|
||||||
|
public string? AttachmentId { get; set; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AttachmentSaveOutcome
|
||||||
|
{
|
||||||
|
Saved,
|
||||||
|
Failed,
|
||||||
|
AuthRequired
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,5 +15,16 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
|
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
|
||||||
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
|
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
|
||||||
Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default);
|
Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default);
|
||||||
|
Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
|
||||||
|
Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<System.Collections.Generic.List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// Marcus-Law NetworkStorageIntegration endpoints (filesystem-based
|
||||||
|
// folder structure under the case folder).
|
||||||
|
Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default);
|
||||||
|
Task<System.Collections.Generic.List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default);
|
||||||
|
Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default);
|
Task<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the full contact + account + recent-cases bundle for a
|
||||||
|
/// specific contact id. Used when the sender's email matches
|
||||||
|
/// multiple contacts and the user picks one from the candidate
|
||||||
|
/// list — we then drill into that contact's cases without
|
||||||
|
/// dropping back to manual search.
|
||||||
|
/// </summary>
|
||||||
|
Task<MatchResult?> LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void Invalidate(string senderEmail);
|
void Invalidate(string senderEmail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MarcusLaw.OutlookAddin.Core.Models;
|
using MarcusLaw.OutlookAddin.Core.Models;
|
||||||
@@ -44,60 +45,51 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.Count > 1)
|
// EspoCRM's /EmailAddress/search can return the same Contact
|
||||||
|
// more than once when the contact has the searched-for
|
||||||
|
// address registered under multiple emailAddressData rows.
|
||||||
|
// Dedupe by Id so the sidebar shows one row per unique person.
|
||||||
|
var distinctIds = matches
|
||||||
|
.Where(m => !string.IsNullOrWhiteSpace(m.Id))
|
||||||
|
.Select(m => m.Id)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.Take(20)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (distinctIds.Count == 0)
|
||||||
{
|
{
|
||||||
var ambiguous = new MatchResult
|
_cache.Set(key, (MatchResult?)null, CacheExpiry);
|
||||||
{
|
return null;
|
||||||
SenderEmail = senderEmail,
|
|
||||||
IsAmbiguous = true,
|
|
||||||
CandidateCount = matches.Count
|
|
||||||
};
|
|
||||||
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
|
|
||||||
return ambiguous;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var contactRef = matches[0];
|
if (distinctIds.Count == 1)
|
||||||
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
AccountEntity? account = null;
|
|
||||||
if (!string.IsNullOrWhiteSpace(contact.AccountId))
|
|
||||||
{
|
{
|
||||||
try
|
var single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
_cache.Set<MatchResult?>(key, single, CacheExpiry);
|
||||||
account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false);
|
return single;
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for {Email}", contact.AccountId, senderEmail);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cases = new System.Collections.Generic.List<CaseEntity>();
|
// Two or more genuinely distinct contacts. Fetch each one's
|
||||||
try
|
// full record (in parallel) so the picker can show real
|
||||||
{
|
// name + account, not just the bare email payload.
|
||||||
// Pull up to 50 cases per contact so the sidebar can show the
|
var fetched = await Task.WhenAll(
|
||||||
// whole list, not just the 5 most recent. 50 is well below the
|
distinctIds.Select(id => SafeGetContactAsync(id, cancellationToken))
|
||||||
// EspoCRM default page limit and large enough for any realistic
|
).ConfigureAwait(false);
|
||||||
// attorney/contact relationship.
|
|
||||||
var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false);
|
|
||||||
cases.AddRange(list.List);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new MatchResult
|
var candidates = fetched
|
||||||
|
.Where(c => c != null && !string.IsNullOrWhiteSpace(c!.Id))
|
||||||
|
.Select(c => c!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var ambiguous = new MatchResult
|
||||||
{
|
{
|
||||||
SenderEmail = senderEmail,
|
SenderEmail = senderEmail,
|
||||||
Contact = contact,
|
IsAmbiguous = true,
|
||||||
Account = account,
|
CandidateCount = candidates.Count,
|
||||||
RecentCases = cases,
|
Candidates = candidates
|
||||||
CandidateCount = 1
|
|
||||||
};
|
};
|
||||||
|
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
|
||||||
_cache.Set<MatchResult?>(key, result, CacheExpiry);
|
return ambiguous;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
catch (EspoCrmAuthorizationException)
|
catch (EspoCrmAuthorizationException)
|
||||||
{
|
{
|
||||||
@@ -114,6 +106,86 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MatchResult?> LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(contactId)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await LoadContactBundleAsync(contactId, senderEmail ?? string.Empty, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (EspoCrmAuthorizationException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "MatchingService: LookupContactAsync failed for {ContactId}", contactId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MatchResult> LoadContactBundleAsync(string contactId, string senderEmail, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contact = await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
AccountEntity? account = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(contact.AccountId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for contact {ContactId}", contact.AccountId, contactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cases = new System.Collections.Generic.List<CaseEntity>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Pull up to 50 cases per contact so the sidebar can show the
|
||||||
|
// whole list, not just the 5 most recent. 50 is well below the
|
||||||
|
// EspoCRM default page limit and large enough for any realistic
|
||||||
|
// attorney/contact relationship.
|
||||||
|
var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false);
|
||||||
|
cases.AddRange(list.List);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MatchResult
|
||||||
|
{
|
||||||
|
SenderEmail = senderEmail,
|
||||||
|
Contact = contact,
|
||||||
|
Account = account,
|
||||||
|
RecentCases = cases,
|
||||||
|
CandidateCount = 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ContactEntity?> SafeGetContactAsync(string contactId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (EspoCrmAuthorizationException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "MatchingService: candidate contact fetch failed for {Id}", contactId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Invalidate(string senderEmail)
|
public void Invalidate(string senderEmail)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(senderEmail)) return;
|
if (string.IsNullOrWhiteSpace(senderEmail)) return;
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ namespace MarcusLaw.OutlookAddin.UI.Converters
|
|||||||
["New"] = "חדש",
|
["New"] = "חדש",
|
||||||
["Assigned"] = "הוקצה",
|
["Assigned"] = "הוקצה",
|
||||||
["Pending"] = "ממתין",
|
["Pending"] = "ממתין",
|
||||||
|
["PendingHearing"] = "ממתין לדיון",
|
||||||
|
["Pending Hearing"] = "ממתין לדיון",
|
||||||
|
["PendingDecision"] = "ממתין להחלטה",
|
||||||
|
["Pending Decision"] = "ממתין להחלטה",
|
||||||
["In Progress"] = "בטיפול",
|
["In Progress"] = "בטיפול",
|
||||||
["InProgress"] = "בטיפול",
|
["InProgress"] = "בטיפול",
|
||||||
["Open"] = "פתוח",
|
["Open"] = "פתוח",
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.SaveAttachmentsDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
|
||||||
|
Title="שמור קבצים מצורפים ל-Klear"
|
||||||
|
Height="560" Width="620"
|
||||||
|
MinHeight="420" MinWidth="500"
|
||||||
|
FlowDirection="RightToLeft"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
Background="#F8FAFC"
|
||||||
|
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
|
||||||
|
<Window.Resources>
|
||||||
|
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVis" />
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
|
||||||
|
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
|
||||||
|
<SolidColorBrush x:Key="Muted" Color="#64748B" />
|
||||||
|
<SolidColorBrush x:Key="CardBg" Color="White" />
|
||||||
|
<SolidColorBrush x:Key="HoverBg" Color="#F1F5F9" />
|
||||||
|
<SolidColorBrush x:Key="SelectedBg" Color="#DBEAFE" />
|
||||||
|
|
||||||
|
<Style x:Key="Card" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBg}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Primary}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="22,8" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="MinWidth" Value="110" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource PrimaryHover}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#94A3B8" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="SecondaryButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="White" />
|
||||||
|
<Setter Property="Foreground" Value="#0F172A" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="20,8" />
|
||||||
|
<Setter Property="MinWidth" Value="110" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="5" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBg}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="16" x:Name="RootGrid">
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style x:Key="CaseGrid" TargetType="DataGrid">
|
||||||
|
<Setter Property="AutoGenerateColumns" Value="False" />
|
||||||
|
<Setter Property="HeadersVisibility" Value="Column" />
|
||||||
|
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||||
|
<Setter Property="HorizontalGridLinesBrush" Value="#E2E8F0" />
|
||||||
|
<Setter Property="RowBackground" Value="White" />
|
||||||
|
<Setter Property="AlternatingRowBackground" Value="#F8FAFC" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="CanUserAddRows" Value="False" />
|
||||||
|
<Setter Property="CanUserDeleteRows" Value="False" />
|
||||||
|
<Setter Property="CanUserResizeRows" Value="False" />
|
||||||
|
<Setter Property="CanUserReorderColumns" Value="False" />
|
||||||
|
<Setter Property="SelectionMode" Value="Single" />
|
||||||
|
<Setter Property="SelectionUnit" Value="FullRow" />
|
||||||
|
<Setter Property="IsReadOnly" Value="True" />
|
||||||
|
<Setter Property="RowHeight" Value="30" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<Border Grid.Row="0" Style="{StaticResource Card}" Padding="10,8" Margin="0,0,0,8">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="🔍" VerticalAlignment="Center" FontSize="16" Margin="0,0,8,0" />
|
||||||
|
<TextBox x:Name="SearchBox"
|
||||||
|
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
BorderThickness="0"
|
||||||
|
Background="Transparent"
|
||||||
|
FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Cases -->
|
||||||
|
<Border Grid.Row="1" Style="{StaticResource Card}">
|
||||||
|
<DataGrid ItemsSource="{Binding Cases}"
|
||||||
|
SelectedItem="{Binding SelectedCase}"
|
||||||
|
Style="{StaticResource CaseGrid}">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
|
||||||
|
<DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
|
||||||
|
<DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
|
||||||
|
<DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Folder -->
|
||||||
|
<DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="טוען…" VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0" Foreground="{StaticResource Muted}" FontSize="12"
|
||||||
|
Visibility="{Binding IsLoadingSubfolders, Converter={StaticResource BoolToVis}}" />
|
||||||
|
<ComboBox ItemsSource="{Binding Subfolders}"
|
||||||
|
SelectedItem="{Binding SelectedSubfolder}"
|
||||||
|
Padding="6,4">
|
||||||
|
<ComboBox.Style>
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsLoadingSubfolders}" Value="True">
|
||||||
|
<Setter Property="IsEnabled" Value="False" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ComboBox.Style>
|
||||||
|
</ComboBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Attachments header -->
|
||||||
|
<TextBlock Grid.Row="3" Margin="0,12,0,4" Foreground="{StaticResource Muted}" FontSize="12">
|
||||||
|
<Run Text="קבצים מצורפים (" /><Run Text="{Binding Attachments.Count, Mode=OneWay}" /><Run Text=")" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<!-- Attachments list -->
|
||||||
|
<Border Grid.Row="4" Style="{StaticResource Card}" MaxHeight="160">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding Attachments}" Margin="6">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<DockPanel Margin="0,3" LastChildFill="True">
|
||||||
|
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding SizeDisplay}"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Foreground="{StaticResource Muted}"
|
||||||
|
FontSize="11"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0" />
|
||||||
|
<TextBlock Text="{Binding Extension}"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Foreground="{StaticResource Muted}"
|
||||||
|
FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="6,0,0,0" />
|
||||||
|
<TextBox Text="{Binding BaseName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
IsEnabled="{Binding IsSelected}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="{StaticResource Border}"
|
||||||
|
Padding="6,3"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
ToolTip="לחץ כדי לערוך את שם הקובץ (הסיומת נשמרת אוטומטית)" />
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Action row -->
|
||||||
|
<DockPanel Grid.Row="5" Margin="0,12,0,0" LastChildFill="True">
|
||||||
|
<TextBlock DockPanel.Dock="Right"
|
||||||
|
Text="{Binding StatusMessage}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
Foreground="{StaticResource Muted}"
|
||||||
|
FontSize="12"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||||
|
<Button Command="{Binding SubmitCommand}"
|
||||||
|
IsDefault="True"
|
||||||
|
Content="שמור"
|
||||||
|
Style="{StaticResource PrimaryButton}"
|
||||||
|
Margin="0,0,8,0" />
|
||||||
|
<Button Command="{Binding CancelCommand}"
|
||||||
|
IsCancel="True"
|
||||||
|
Content="ביטול"
|
||||||
|
Style="{StaticResource SecondaryButton}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
||||||
|
{
|
||||||
|
public partial class SaveAttachmentsDialog : Window
|
||||||
|
{
|
||||||
|
private SaveAttachmentsViewModel? _viewModel;
|
||||||
|
|
||||||
|
public SaveAttachmentsDialog()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Loaded += (_, __) => SearchBox.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public SaveAttachmentsDialog(SaveAttachmentsViewModel viewModel) : this()
|
||||||
|
{
|
||||||
|
AttachViewModel(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AttachViewModel(SaveAttachmentsViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (_viewModel != null) _viewModel.RequestClose -= OnRequestClose;
|
||||||
|
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
|
||||||
|
DataContext = _viewModel;
|
||||||
|
_viewModel.RequestClose += OnRequestClose;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRequestClose(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = _viewModel?.DialogResult;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,13 +98,47 @@
|
|||||||
|
|
||||||
<TabItem Header="אודות">
|
<TabItem Header="אודות">
|
||||||
<StackPanel Margin="16">
|
<StackPanel Margin="16">
|
||||||
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
|
<Border Background="#121d2e" CornerRadius="6" Padding="14,12" Margin="0,0,0,12">
|
||||||
<TextBlock Text="תוסף Klear ל-Microsoft Outlook" Margin="0,4,0,12" />
|
<DockPanel LastChildFill="True">
|
||||||
<TextBlock Text="{Binding VersionLine}" Foreground="Gray" />
|
<Image DockPanel.Dock="Right"
|
||||||
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap"
|
Source="pack://application:,,,/MarcusLaw.OutlookAddin.UI;component/Resources/logo-klear.png"
|
||||||
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
|
Height="36"
|
||||||
<TextBlock TextWrapping="Wrap"
|
Margin="12,0,0,0"
|
||||||
Text="הגדרות: %APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
|
VerticalAlignment="Center" />
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Klear" Foreground="White" FontWeight="Bold" FontSize="18" />
|
||||||
|
<TextBlock Text="תוסף ל-Microsoft Outlook" Foreground="#CBD5E1" FontSize="12" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<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">
|
||||||
|
<Run Text="לוגים: " />
|
||||||
|
<Hyperlink Tag="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs"
|
||||||
|
Click="OnOpenFolderHyperlink"
|
||||||
|
ToolTip="פתח את התיקייה ב-Explorer">
|
||||||
|
<Run Text="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
|
||||||
|
</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock TextWrapping="Wrap">
|
||||||
|
<Run Text="הגדרות: " />
|
||||||
|
<Hyperlink Tag="%APPDATA%\MarcusLaw\OutlookAddin"
|
||||||
|
Click="OnOpenFolderHyperlink"
|
||||||
|
ToolTip="פתח את התיקייה ב-Explorer">
|
||||||
|
<Run Text="%APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
|
||||||
|
</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Documents;
|
||||||
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
using MarcusLaw.OutlookAddin.UI.ViewModels;
|
||||||
|
|
||||||
namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
||||||
@@ -40,5 +43,23 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs
|
|||||||
DialogResult = _viewModel?.DialogResult;
|
DialogResult = _viewModel?.DialogResult;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnOpenFolderHyperlink(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!(sender is Hyperlink h) || !(h.Tag is string raw)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = Environment.ExpandEnvironmentVariables(raw);
|
||||||
|
// Create the folder lazily so the link works even on the very
|
||||||
|
// first run, before any log has been written.
|
||||||
|
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
||||||
|
Process.Start(new ProcessStartInfo("explorer.exe", "\"" + path + "\"") { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show("פתיחת התיקייה נכשלה: " + ex.Message, "Klear",
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,15 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="System.Net.Http" />
|
<Reference Include="System.Net.Http" />
|
||||||
|
<Reference Include="System.Deployment" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OutlookAddin.Core\OutlookAddin.Core.csproj" />
|
<ProjectReference Include="..\OutlookAddin.Core\OutlookAddin.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="Resources\logo-klear.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
using System.IO;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using MarcusLaw.OutlookAddin.Core.Models;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||||
|
{
|
||||||
|
public sealed partial class AttachmentRowViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public EspoAttachment Data { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isSelected = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frozen at construction from the original filename. The user
|
||||||
|
/// edits only the base name; the extension always tags along.
|
||||||
|
/// </summary>
|
||||||
|
public string Extension { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Editable base name without the extension. Setting this rewrites
|
||||||
|
/// <see cref="EspoAttachment.Name"/> as <basename><Extension>.
|
||||||
|
/// </summary>
|
||||||
|
public string BaseName
|
||||||
|
{
|
||||||
|
get => Path.GetFileNameWithoutExtension(Data.Name) ?? string.Empty;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var newBase = (value ?? string.Empty).Trim();
|
||||||
|
var newFull = newBase + Extension;
|
||||||
|
if (Data.Name == newFull) return;
|
||||||
|
Data.Name = newFull;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(FileName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full filename including extension — what actually goes to the
|
||||||
|
/// server. Read-only mirror of <see cref="EspoAttachment.Name"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string FileName => Data.Name;
|
||||||
|
|
||||||
|
public string SizeDisplay { get; }
|
||||||
|
|
||||||
|
public AttachmentRowViewModel(EspoAttachment data)
|
||||||
|
{
|
||||||
|
Data = data;
|
||||||
|
Extension = Path.GetExtension(data.Name) ?? string.Empty;
|
||||||
|
SizeDisplay = ComputeSize(data.ContentBase64);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSize(string base64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(base64)) return string.Empty;
|
||||||
|
// Base64 expands binary 4-for-3, ignoring padding chars.
|
||||||
|
var padding = 0;
|
||||||
|
if (base64.Length > 0 && base64[base64.Length - 1] == '=') padding++;
|
||||||
|
if (base64.Length > 1 && base64[base64.Length - 2] == '=') padding++;
|
||||||
|
var bytes = (long)((base64.Length * 3) / 4) - padding;
|
||||||
|
return FormatBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatBytes(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("F1") + " KB";
|
||||||
|
return (bytes / (1024.0 * 1024.0)).ToString("F1") + " MB";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
private readonly IMatchingService _matchingService;
|
private readonly IMatchingService _matchingService;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private CancellationTokenSource? _activeLookup;
|
private CancellationTokenSource? _activeLookup;
|
||||||
|
// Stashes the ambiguous MatchResult while the user is drilling into
|
||||||
|
// one of its candidates, so the "→ חזור" button can restore it.
|
||||||
|
private MatchResult? _ambiguousReturnTarget;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ReadingPaneState state = ReadingPaneState.Empty;
|
private ReadingPaneState state = ReadingPaneState.Empty;
|
||||||
@@ -51,6 +54,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
|
|
||||||
public ObservableCollection<CaseEntity> RecentCases { get; } = new ObservableCollection<CaseEntity>();
|
public ObservableCollection<CaseEntity> RecentCases { get; } = new ObservableCollection<CaseEntity>();
|
||||||
|
|
||||||
|
public ObservableCollection<ContactEntity> Candidates { get; } = new ObservableCollection<ContactEntity>();
|
||||||
|
|
||||||
public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty);
|
public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty);
|
||||||
public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading);
|
public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading);
|
||||||
public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match);
|
public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match);
|
||||||
@@ -61,6 +66,22 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0);
|
public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0);
|
||||||
public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match);
|
public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the matched contact has 2+ linked cases. The sidebar
|
||||||
|
/// uses this to hide the bottom "תייק לתיק הזה" button (which is
|
||||||
|
/// ambiguous in that scenario) and to surface the per-row "תייק"
|
||||||
|
/// buttons that let the user pick which case to file to.
|
||||||
|
/// </summary>
|
||||||
|
public Visibility MultiCaseHintVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count >= 2);
|
||||||
|
public Visibility SingleCaseFileVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "→ חזור לרשימת אנשי קשר" is only shown when the user drilled
|
||||||
|
/// into a candidate from the ambiguous picker; not in a normal
|
||||||
|
/// single-match lookup.
|
||||||
|
/// </summary>
|
||||||
|
public Visibility BackToCandidatesVisibility => Vis(State == ReadingPaneState.Match && _ambiguousReturnTarget != null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when the user clicks "File to this case" (carries the chosen
|
/// Raised when the user clicks "File to this case" (carries the chosen
|
||||||
/// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null).
|
/// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null).
|
||||||
@@ -89,6 +110,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
OnPropertyChanged(nameof(ErrorVisibility));
|
OnPropertyChanged(nameof(ErrorVisibility));
|
||||||
OnPropertyChanged(nameof(ActionsVisibility));
|
OnPropertyChanged(nameof(ActionsVisibility));
|
||||||
OnPropertyChanged(nameof(NoCasesVisibility));
|
OnPropertyChanged(nameof(NoCasesVisibility));
|
||||||
|
OnPropertyChanged(nameof(MultiCaseHintVisibility));
|
||||||
|
OnPropertyChanged(nameof(SingleCaseFileVisibility));
|
||||||
|
OnPropertyChanged(nameof(BackToCandidatesVisibility));
|
||||||
FileToThisCommand.NotifyCanExecuteChanged();
|
FileToThisCommand.NotifyCanExecuteChanged();
|
||||||
FileElsewhereCommand.NotifyCanExecuteChanged();
|
FileElsewhereCommand.NotifyCanExecuteChanged();
|
||||||
OpenInEspoCrmCommand.NotifyCanExecuteChanged();
|
OpenInEspoCrmCommand.NotifyCanExecuteChanged();
|
||||||
@@ -101,8 +125,16 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
{
|
{
|
||||||
foreach (var c in value.RecentCases) RecentCases.Add(c);
|
foreach (var c in value.RecentCases) RecentCases.Add(c);
|
||||||
}
|
}
|
||||||
|
Candidates.Clear();
|
||||||
|
if (value?.Candidates != null)
|
||||||
|
{
|
||||||
|
foreach (var c in value.Candidates) Candidates.Add(c);
|
||||||
|
}
|
||||||
OnPropertyChanged(nameof(AccountVisibility));
|
OnPropertyChanged(nameof(AccountVisibility));
|
||||||
OnPropertyChanged(nameof(NoCasesVisibility));
|
OnPropertyChanged(nameof(NoCasesVisibility));
|
||||||
|
OnPropertyChanged(nameof(MultiCaseHintVisibility));
|
||||||
|
OnPropertyChanged(nameof(SingleCaseFileVisibility));
|
||||||
|
OnPropertyChanged(nameof(BackToCandidatesVisibility));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Reset()
|
public void Reset()
|
||||||
@@ -112,6 +144,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
Match = null;
|
Match = null;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
AmbiguousMessage = null;
|
AmbiguousMessage = null;
|
||||||
|
_ambiguousReturnTarget = null;
|
||||||
State = ReadingPaneState.Empty;
|
State = ReadingPaneState.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +158,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
}
|
}
|
||||||
|
|
||||||
SenderEmail = senderEmail;
|
SenderEmail = senderEmail;
|
||||||
|
// New email selection — drop any stashed candidate-list state.
|
||||||
|
_ambiguousReturnTarget = null;
|
||||||
State = ReadingPaneState.Loading;
|
State = ReadingPaneState.Loading;
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
var cts = new CancellationTokenSource();
|
||||||
@@ -182,7 +217,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!));
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null;
|
// Bottom "תייק לתיק הזה" only makes sense with exactly one linked
|
||||||
|
// case. 0 cases → nothing to file to; 2+ cases → ambiguous, the
|
||||||
|
// per-row "תייק" buttons handle the pick.
|
||||||
|
private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null && RecentCases.Count == 1;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void FileToCase(CaseEntity? caseEntity)
|
||||||
|
{
|
||||||
|
if (caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id) || Match == null) return;
|
||||||
|
// Build a synthetic MatchResult so the host's
|
||||||
|
// TryResolveSidebarAutoTarget path sees exactly one case and
|
||||||
|
// auto-files without re-opening the search dialog.
|
||||||
|
var synthetic = new MatchResult
|
||||||
|
{
|
||||||
|
SenderEmail = Match.SenderEmail,
|
||||||
|
Contact = Match.Contact,
|
||||||
|
Account = Match.Account,
|
||||||
|
RecentCases = new System.Collections.Generic.List<CaseEntity> { caseEntity },
|
||||||
|
CandidateCount = 1
|
||||||
|
};
|
||||||
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(synthetic));
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanFileElsewhere))]
|
[RelayCommand(CanExecute = nameof(CanFileElsewhere))]
|
||||||
private void FileElsewhere()
|
private void FileElsewhere()
|
||||||
@@ -202,12 +258,88 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
|
|
||||||
private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null;
|
private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void OpenCaseInCrm(CaseEntity? caseEntity)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id)) return;
|
||||||
|
var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Case/view/" + caseEntity.Id;
|
||||||
|
OpenInBrowserRequested?.Invoke(this, url);
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenManualSearch()
|
private void OpenManualSearch()
|
||||||
{
|
{
|
||||||
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void BackToCandidates()
|
||||||
|
{
|
||||||
|
if (_ambiguousReturnTarget == null) return;
|
||||||
|
var stash = _ambiguousReturnTarget;
|
||||||
|
// Clear stash before we re-enter Ambiguous so visibility refresh
|
||||||
|
// (triggered by OnMatchChanged) sees the cleared state and hides
|
||||||
|
// the back button until the user drills in again.
|
||||||
|
_ambiguousReturnTarget = null;
|
||||||
|
Match = stash;
|
||||||
|
AmbiguousMessage = $"נמצאו {stash.CandidateCount} אנשי קשר עם הכתובת הזו.";
|
||||||
|
State = ReadingPaneState.Ambiguous;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User picked one of the ambiguous candidates from the sidebar.
|
||||||
|
/// Load that contact's full bundle (account + cases) and transition
|
||||||
|
/// to the Match state so the user can pick a case or file.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SelectCandidateAsync(ContactEntity? candidate)
|
||||||
|
{
|
||||||
|
if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return;
|
||||||
|
|
||||||
|
CancelInFlight();
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_activeLookup = cts;
|
||||||
|
|
||||||
|
var rememberedEmail = SenderEmail;
|
||||||
|
// Stash the ambiguous result so "→ חזור" can restore it.
|
||||||
|
if (Match?.IsAmbiguous == true)
|
||||||
|
{
|
||||||
|
_ambiguousReturnTarget = Match;
|
||||||
|
}
|
||||||
|
State = ReadingPaneState.Loading;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _matchingService.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true);
|
||||||
|
if (cts.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
if (result == null || result.Contact == null)
|
||||||
|
{
|
||||||
|
ErrorMessage = "טעינת איש הקשר נכשלה.";
|
||||||
|
State = ReadingPaneState.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Match = result;
|
||||||
|
State = ReadingPaneState.Match;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Selection changed before lookup finished — ignore.
|
||||||
|
}
|
||||||
|
catch (EspoCrmAuthorizationException)
|
||||||
|
{
|
||||||
|
ErrorMessage = "מפתח ה-API נדחה. עדכן בהגדרות.";
|
||||||
|
State = ReadingPaneState.Error;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "ReadingPaneViewModel.SelectCandidateAsync failed for {ContactId}", candidate.Id);
|
||||||
|
ErrorMessage = "שגיאת התחברות לשרת.";
|
||||||
|
State = ReadingPaneState.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed;
|
private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using MarcusLaw.OutlookAddin.Core.Models;
|
||||||
|
using MarcusLaw.OutlookAddin.Core.Services;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Picks a Case + optional subfolder (filesystem-style, via Marcus-Law's
|
||||||
|
/// NetworkStorageIntegration) for saving raw attachment files.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class SaveAttachmentsViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
// Default subfolders the integration creates under each Case folder.
|
||||||
|
// Mirrors Resources/metadata/app/networkStorage.json on the server.
|
||||||
|
private static readonly string[] DefaultCaseSubfolders =
|
||||||
|
{
|
||||||
|
"מסמכים",
|
||||||
|
"אסמכתאות",
|
||||||
|
"התכתבויות",
|
||||||
|
"כללי"
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string RootSentinel = "(שורש התיק)";
|
||||||
|
|
||||||
|
private readonly IEspoCrmClient _client;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string searchText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private CaseEntity? selectedCase;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? selectedSubfolder;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? statusMessage;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isLoadingSubfolders;
|
||||||
|
|
||||||
|
/// <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>();
|
||||||
|
|
||||||
|
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
|
||||||
|
|
||||||
|
public bool? DialogResult { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<EspoAttachment> SelectedAttachments
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var list = new List<EspoAttachment>();
|
||||||
|
foreach (var row in Attachments)
|
||||||
|
{
|
||||||
|
if (row.IsSelected) list.Add(row.Data);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subfolder string the user picked, or null to save to the case
|
||||||
|
/// folder root.
|
||||||
|
/// </summary>
|
||||||
|
public string? ChosenSubfolderName =>
|
||||||
|
string.IsNullOrEmpty(SelectedSubfolder) ||
|
||||||
|
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
|
||||||
|
? null
|
||||||
|
: SelectedSubfolder;
|
||||||
|
|
||||||
|
public CaseEntity? PickedCase => SelectedCase;
|
||||||
|
|
||||||
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
|
public SaveAttachmentsViewModel(
|
||||||
|
IEspoCrmClient client,
|
||||||
|
ILogger logger,
|
||||||
|
IEnumerable<EspoAttachment> attachments)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
|
||||||
|
ResetSubfolders();
|
||||||
|
|
||||||
|
foreach (var att in attachments)
|
||||||
|
{
|
||||||
|
Attachments.Add(new AttachmentRowViewModel(att));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSubfolders()
|
||||||
|
{
|
||||||
|
Subfolders.Clear();
|
||||||
|
Subfolders.Add(RootSentinel);
|
||||||
|
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
|
||||||
|
SelectedSubfolder = Subfolders[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSearchTextChanged(string value)
|
||||||
|
{
|
||||||
|
_searchCts?.Cancel();
|
||||||
|
_searchCts = new CancellationTokenSource();
|
||||||
|
_ = SearchAsync(value, _searchCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedCaseChanged(CaseEntity? value)
|
||||||
|
{
|
||||||
|
SubmitCommand.NotifyCanExecuteChanged();
|
||||||
|
_ = RefreshSubfoldersForSelectedCaseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes the subfolder dropdown to merge static defaults with
|
||||||
|
/// the actual folders that already exist on the network share for
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
IsLoadingSubfolders = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RefreshSubfoldersCoreAsync(c).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLoadingSubfolders = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshSubfoldersCoreAsync(CaseEntity c)
|
||||||
|
{
|
||||||
|
try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
|
||||||
|
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
|
||||||
|
|
||||||
|
string? primaryContactName = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detail = await _client.GetCaseAsync(
|
||||||
|
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, 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
|
||||||
|
{
|
||||||
|
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (!item.IsFolder) continue;
|
||||||
|
if (Subfolders.Contains(item.Name)) continue;
|
||||||
|
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
|
||||||
|
Subfolders.Add(item.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameForFolder = !string.IsNullOrWhiteSpace(primaryContactName)
|
||||||
|
? primaryContactName!
|
||||||
|
: (c.Name ?? string.Empty);
|
||||||
|
|
||||||
|
var raw = !string.IsNullOrWhiteSpace(c.Number)
|
||||||
|
? $"{c.Number}-{nameForFolder}"
|
||||||
|
: nameForFolder;
|
||||||
|
return SanitizeFolderSegment(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors LocalFilesystemClient::sanitizeFileName on the server side
|
||||||
|
/// so client-computed paths match what the integration writes.
|
||||||
|
/// </summary>
|
||||||
|
public static string SanitizeFolderSegment(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return string.Empty;
|
||||||
|
foreach (var c in new[] { '׳', '״', '\'', '"', '‘', '’', '“', '”' })
|
||||||
|
{
|
||||||
|
name = name.Replace(c.ToString(), string.Empty);
|
||||||
|
}
|
||||||
|
foreach (var c in new[] { '/', '\\', ':', '*', '?', '<', '>', '|' })
|
||||||
|
{
|
||||||
|
name = name.Replace(c, '-');
|
||||||
|
}
|
||||||
|
while (name.Contains("--")) name = name.Replace("--", "-");
|
||||||
|
while (name.Contains(" ")) name = name.Replace(" ", " ");
|
||||||
|
name = name.Replace("-.", ".");
|
||||||
|
return name.Trim(' ', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
|
||||||
|
if (string.IsNullOrEmpty(hit.Id)) return row;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var detail = await _client.GetCaseAsync(
|
||||||
|
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
|
||||||
|
row.Number = detail.Number;
|
||||||
|
row.Status = detail.Status;
|
||||||
|
row.CourtCaseNumber = detail.CourtCaseNumber;
|
||||||
|
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "Hydrating case {CaseId} for picker failed", hit.Id);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SearchAsync(string query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(250, ct).ConfigureAwait(true);
|
||||||
|
|
||||||
|
var trimmed = (query ?? string.Empty).Trim();
|
||||||
|
if (trimmed.Length < 2)
|
||||||
|
{
|
||||||
|
Cases.Clear();
|
||||||
|
StatusMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
Cases.Clear();
|
||||||
|
var caseHits = new List<EspoEntityRef>();
|
||||||
|
foreach (var hit in search.List)
|
||||||
|
{
|
||||||
|
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
|
||||||
|
caseHits.Add(hit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caseHits.Count == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "אין תוצאות מסוג תיק (Case)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
|
||||||
|
foreach (var hit in caseHits)
|
||||||
|
{
|
||||||
|
detailTasks.Add(LoadCaseRowAsync(hit, ct));
|
||||||
|
}
|
||||||
|
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
foreach (var row in details)
|
||||||
|
{
|
||||||
|
if (row != null) Cases.Add(row);
|
||||||
|
}
|
||||||
|
StatusMessage = $"נמצאו {Cases.Count} תיקים";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* superseded */ }
|
||||||
|
catch (EspoCrmAuthorizationException)
|
||||||
|
{
|
||||||
|
StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning(ex, "SaveAttachments search failed");
|
||||||
|
StatusMessage = "שגיאת חיפוש: " + ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||||
|
private void Submit()
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanSubmit()
|
||||||
|
{
|
||||||
|
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return false;
|
||||||
|
foreach (var row in Attachments)
|
||||||
|
{
|
||||||
|
if (row.IsSelected) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
@@ -40,6 +41,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; }
|
||||||
@@ -70,8 +80,56 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|||||||
Username = creds?.Username ?? string.Empty;
|
Username = creds?.Username ?? string.Empty;
|
||||||
InitialApiKey = creds?.ApiKey ?? string.Empty;
|
InitialApiKey = creds?.ApiKey ?? string.Empty;
|
||||||
|
|
||||||
var version = typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
VersionLine = $"גרסה {ResolveDisplayVersion()}";
|
||||||
VersionLine = $"גרסה {version}";
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
@@ -105,6 +163,136 @@ 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 currentVersion = deployment.CurrentVersion;
|
||||||
|
var manifestUri = deployment.UpdateLocation;
|
||||||
|
|
||||||
|
// ApplicationDeployment.CheckForDetailedUpdate triggers a
|
||||||
|
// TrustManager re-evaluation which throws TrustNotGranted
|
||||||
|
// inside a hosted VSTO process even when the publisher and
|
||||||
|
// permissions are unchanged. Bypass it by fetching the
|
||||||
|
// deployment manifest directly and reading the version.
|
||||||
|
Version? availableVersion = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
availableVersion = await Task.Run(() => FetchManifestVersion(manifestUri)).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception fetchEx)
|
||||||
|
{
|
||||||
|
_logger.Warning(fetchEx, "CheckForUpdate: manifest fetch failed");
|
||||||
|
UpdateStatusBrush = ErrorBrush;
|
||||||
|
UpdateStatus = "שגיאה בבדיקת השרת: " + fetchEx.Message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableVersion == null)
|
||||||
|
{
|
||||||
|
UpdateStatusBrush = ErrorBrush;
|
||||||
|
UpdateStatus = "לא ניתן לקרוא את גרסת השרת מ-" + manifestUri;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableVersion <= currentVersion)
|
||||||
|
{
|
||||||
|
UpdateStatusBrush = SuccessBrush;
|
||||||
|
UpdateStatus = "אתה על הגרסה האחרונה (" + currentVersion + ").";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VSTO add-ins cannot be updated programmatically from inside
|
||||||
|
// a running Outlook — ApplicationDeployment.Update() throws.
|
||||||
|
// Open the .vsto URL via the shell; Windows hands it to
|
||||||
|
// VSTOInstaller.exe which installs the update correctly.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(manifestUri.ToString())
|
||||||
|
{
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
UpdateStatusBrush = SuccessBrush;
|
||||||
|
UpdateStatus = $"זמינה גרסה {availableVersion} — אשר את ההתקנה בחלון שנפתח, ואז סגור ופתח את Outlook.";
|
||||||
|
}
|
||||||
|
catch (Exception launchEx)
|
||||||
|
{
|
||||||
|
_logger.Warning(launchEx, "CheckForUpdate: shell launch of installer URL failed");
|
||||||
|
UpdateStatusBrush = ErrorBrush;
|
||||||
|
UpdateStatus = $"זמינה גרסה {availableVersion} אבל לא הצלחנו לפתוח את חלון ההתקנה. גש ידנית ל-{manifestUri}";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the top-level <assemblyIdentity> version from the
|
||||||
|
/// .vsto deployment manifest at <paramref name="manifestUri"/>.
|
||||||
|
/// Returns null if the document doesn't look like a deployment
|
||||||
|
/// manifest. Runs on a worker thread (synchronous HTTP).
|
||||||
|
/// </summary>
|
||||||
|
private static Version? FetchManifestVersion(Uri manifestUri)
|
||||||
|
{
|
||||||
|
// The .vsto manifest is small (≈6KB). Keep this synchronous —
|
||||||
|
// no point pulling in HttpClient and its lifecycle just for one
|
||||||
|
// GET. WebRequest works fine on .NET FW 4.8 and through proxies.
|
||||||
|
var req = System.Net.WebRequest.Create(manifestUri);
|
||||||
|
req.Timeout = 10_000;
|
||||||
|
using (var response = req.GetResponse())
|
||||||
|
using (var stream = response.GetResponseStream())
|
||||||
|
{
|
||||||
|
var doc = System.Xml.Linq.XDocument.Load(stream);
|
||||||
|
System.Xml.Linq.XNamespace asmv1 = "urn:schemas-microsoft-com:asm.v1";
|
||||||
|
var identity = doc.Root?
|
||||||
|
.Elements()
|
||||||
|
.FirstOrDefault(e => e.Name.LocalName == "assemblyIdentity" && e.Name.Namespace == asmv1);
|
||||||
|
var versionAttr = identity?.Attribute("version")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(versionAttr)) return null;
|
||||||
|
return Version.TryParse(versionAttr, out var v) ? v : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCheckForUpdate() => !IsCheckingForUpdate;
|
||||||
|
|
||||||
|
partial void OnIsCheckingForUpdateChanged(bool value)
|
||||||
|
{
|
||||||
|
CheckForUpdateCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task TestConnectionAsync()
|
private async Task TestConnectionAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,29 @@
|
|||||||
<!-- Ambiguous -->
|
<!-- Ambiguous -->
|
||||||
<StackPanel Visibility="{Binding AmbiguousVisibility}">
|
<StackPanel Visibility="{Binding AmbiguousVisibility}">
|
||||||
<TextBlock Text="{Binding AmbiguousMessage}" Foreground="DarkOrange" />
|
<TextBlock Text="{Binding AmbiguousMessage}" Foreground="DarkOrange" />
|
||||||
|
<TextBlock Style="{StaticResource LabelStyle}" Text="בחר איש קשר:" Margin="0,8,0,2" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Candidates}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
|
||||||
|
<Button Command="{Binding DataContext.SelectCandidateCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="0"
|
||||||
|
Cursor="Hand"
|
||||||
|
HorizontalContentAlignment="Right"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<StackPanel HorizontalAlignment="Stretch">
|
||||||
|
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" Foreground="#0066CC" />
|
||||||
|
<TextBlock Text="{Binding AccountName}" Foreground="DimGray" FontSize="11" />
|
||||||
|
<TextBlock Text="{Binding EmailAddress}" Foreground="Gray" FontSize="10" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
<Button Content="פתח חיפוש ידני"
|
<Button Content="פתח חיפוש ידני"
|
||||||
Command="{Binding OpenManualSearchCommand}"
|
Command="{Binding OpenManualSearchCommand}"
|
||||||
Style="{StaticResource LinkButtonStyle}"
|
Style="{StaticResource LinkButtonStyle}"
|
||||||
@@ -70,8 +93,20 @@
|
|||||||
|
|
||||||
<!-- Match -->
|
<!-- Match -->
|
||||||
<StackPanel Visibility="{Binding MatchVisibility}">
|
<StackPanel Visibility="{Binding MatchVisibility}">
|
||||||
|
<Button Content="→ חזור לרשימת אנשי קשר"
|
||||||
|
Command="{Binding BackToCandidatesCommand}"
|
||||||
|
Style="{StaticResource LinkButtonStyle}"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
Visibility="{Binding BackToCandidatesVisibility}" />
|
||||||
<TextBlock Style="{StaticResource LabelStyle}" Text="איש קשר" />
|
<TextBlock Style="{StaticResource LabelStyle}" Text="איש קשר" />
|
||||||
<TextBlock Text="{Binding Match.Contact.Name}" FontSize="14" FontWeight="Bold" />
|
<TextBlock FontSize="14" FontWeight="Bold">
|
||||||
|
<Hyperlink Command="{Binding OpenInEspoCrmCommand}"
|
||||||
|
TextDecorations="{x:Null}"
|
||||||
|
Foreground="#0066CC">
|
||||||
|
<Run Text="{Binding Match.Contact.Name, Mode=OneWay}" />
|
||||||
|
</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
<TextBlock Text="{Binding Match.Contact.EmailAddress}" Foreground="Gray" />
|
<TextBlock Text="{Binding Match.Contact.EmailAddress}" Foreground="Gray" />
|
||||||
|
|
||||||
<TextBlock Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" />
|
<TextBlock Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" />
|
||||||
@@ -82,14 +117,33 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
|
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
|
||||||
<StackPanel>
|
<Grid>
|
||||||
<TextBlock>
|
<Grid.ColumnDefinitions>
|
||||||
<Run Text="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
|
<ColumnDefinition Width="*" />
|
||||||
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</TextBlock>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}"
|
<StackPanel Grid.Column="0">
|
||||||
Foreground="Gray" FontSize="10" />
|
<TextBlock>
|
||||||
</StackPanel>
|
<Hyperlink Command="{Binding DataContext.OpenCaseInCrmCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
TextDecorations="{x:Null}"
|
||||||
|
Foreground="#0066CC">
|
||||||
|
<Run Text="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
|
||||||
|
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
|
||||||
|
</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}"
|
||||||
|
Foreground="Gray" FontSize="10" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="תייק"
|
||||||
|
Command="{Binding DataContext.FileToCaseCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Padding="8,2"
|
||||||
|
Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
ToolTip="תייק את המייל הנוכחי לתיק הזה" />
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
@@ -110,7 +164,12 @@
|
|||||||
<Button Content="תייק לתיק הזה"
|
<Button Content="תייק לתיק הזה"
|
||||||
Command="{Binding FileToThisCommand}"
|
Command="{Binding FileToThisCommand}"
|
||||||
Padding="8,4" Margin="0,0,0,4"
|
Padding="8,4" Margin="0,0,0,4"
|
||||||
HorizontalAlignment="Stretch" />
|
HorizontalAlignment="Stretch"
|
||||||
|
Visibility="{Binding SingleCaseFileVisibility}" />
|
||||||
|
<TextBlock Text="יש כמה תיקים מקושרים — בחר תיק מהרשימה למעלה."
|
||||||
|
Foreground="Gray" FontStyle="Italic" FontSize="10"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
Visibility="{Binding MultiCaseHintVisibility}" />
|
||||||
<Button Content="תייק למקום אחר…"
|
<Button Content="תייק למקום אחר…"
|
||||||
Command="{Binding FileElsewhereCommand}"
|
Command="{Binding FileElsewhereCommand}"
|
||||||
Padding="8,4" Margin="0,0,0,4"
|
Padding="8,4" Margin="0,0,0,4"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 647 B |
@@ -31,13 +31,13 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<DefineConstants>VSTO40;UseOfficeInterop</DefineConstants>
|
<DefineConstants>VSTO40;UseOfficeInterop</DefineConstants>
|
||||||
<ResolveComReferenceSilent>true</ResolveComReferenceSilent>
|
<ResolveComReferenceSilent>true</ResolveComReferenceSilent>
|
||||||
<IsWebBootstrapper>False</IsWebBootstrapper>
|
<IsWebBootstrapper>True</IsWebBootstrapper>
|
||||||
<BootstrapperEnabled>true</BootstrapperEnabled>
|
<BootstrapperEnabled>true</BootstrapperEnabled>
|
||||||
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl>
|
<PublishUrl>C:\Users\Chaim\source\repos\OutlookAddin\Publish\</PublishUrl>
|
||||||
<InstallUrl>\\192.168.10.97\Projects\LegalCRM\Add-in\</InstallUrl>
|
<InstallUrl>https://gitea.dev.marcus-law.co.il/espocrm-extensions/OutlookAddin/raw/branch/main/Publish/</InstallUrl>
|
||||||
<TargetCulture>en</TargetCulture>
|
<TargetCulture>en</TargetCulture>
|
||||||
<ApplicationVersion>1.0.0.4</ApplicationVersion>
|
<ApplicationVersion>1.2.4.0</ApplicationVersion>
|
||||||
<AutoIncrementApplicationRevision>true</AutoIncrementApplicationRevision>
|
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
|
||||||
<UpdateEnabled>true</UpdateEnabled>
|
<UpdateEnabled>true</UpdateEnabled>
|
||||||
<UpdateInterval>7</UpdateInterval>
|
<UpdateInterval>7</UpdateInterval>
|
||||||
<UpdateIntervalUnits>days</UpdateIntervalUnits>
|
<UpdateIntervalUnits>days</UpdateIntervalUnits>
|
||||||
@@ -270,6 +270,9 @@
|
|||||||
<EmbeddedResource Include="Images\klear-compose.png">
|
<EmbeddedResource Include="Images\klear-compose.png">
|
||||||
<LogicalName>OutlookAddin.Images.klear-compose.png</LogicalName>
|
<LogicalName>OutlookAddin.Images.klear-compose.png</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="Images\klear-attach.png">
|
||||||
|
<LogicalName>OutlookAddin.Images.klear-attach.png</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
<Compile Include="Services\AddInHost.cs">
|
<Compile Include="Services\AddInHost.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ using System.Security;
|
|||||||
// You can specify all the values or you can default the Build and Revision Numbers
|
// You can specify all the values or you can default the Build and Revision Numbers
|
||||||
// by using the '*' as shown below:
|
// by using the '*' as shown below:
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
// [assembly: AssemblyVersion("1.0.*")]
|
||||||
[assembly: AssemblyVersion("1.0.0.0")]
|
[assembly: AssemblyVersion("1.2.4.0")]
|
||||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
[assembly: AssemblyFileVersion("1.2.4.0")]
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,15 @@ namespace OutlookAddin.Ribbon
|
|||||||
{
|
{
|
||||||
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
|
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
|
||||||
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
|
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
|
||||||
private const string FileButtonId = "FileToEspoCrm";
|
|
||||||
|
// Every button whose `getEnabled` depends on the current Explorer
|
||||||
|
// selection must be re-evaluated on every SelectionChange — Office
|
||||||
|
// caches the initial result otherwise and the button stays stuck.
|
||||||
|
private static readonly string[] SelectionDependentButtons =
|
||||||
|
{
|
||||||
|
"FileToEspoCrm",
|
||||||
|
"SaveAttachments"
|
||||||
|
};
|
||||||
|
|
||||||
private IRibbonUI? _explorerRibbon;
|
private IRibbonUI? _explorerRibbon;
|
||||||
private IRibbonUI? _inspectorRibbon;
|
private IRibbonUI? _inspectorRibbon;
|
||||||
@@ -45,7 +53,7 @@ namespace OutlookAddin.Ribbon
|
|||||||
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
|
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
|
||||||
if (explorer != null)
|
if (explorer != null)
|
||||||
{
|
{
|
||||||
explorer.SelectionChange += () => _explorerRibbon?.InvalidateControl(FileButtonId);
|
explorer.SelectionChange += InvalidateSelectionDependentButtons;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -54,6 +62,16 @@ namespace OutlookAddin.Ribbon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InvalidateSelectionDependentButtons()
|
||||||
|
{
|
||||||
|
var ribbon = _explorerRibbon;
|
||||||
|
if (ribbon == null) return;
|
||||||
|
foreach (var id in SelectionDependentButtons)
|
||||||
|
{
|
||||||
|
try { ribbon.InvalidateControl(id); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void OnInspectorRibbonLoad(IRibbonUI ribbon)
|
public void OnInspectorRibbonLoad(IRibbonUI ribbon)
|
||||||
{
|
{
|
||||||
_inspectorRibbon = ribbon;
|
_inspectorRibbon = ribbon;
|
||||||
@@ -92,6 +110,43 @@ namespace OutlookAddin.Ribbon
|
|||||||
Globals.ThisAddIn.AddInHost.ShowSidebar();
|
Globals.ThisAddIn.AddInHost.ShowSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool OnGetSaveAttachmentsEnabled(IRibbonControl control)
|
||||||
|
{
|
||||||
|
// Enabled only when EXACTLY one MailItem is selected AND it has
|
||||||
|
// at least one attachment. Doing multi-mail save would conflict
|
||||||
|
// with the per-file checkbox UX in the dialog.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
|
||||||
|
if (selection == null || selection.Count != 1) return false;
|
||||||
|
if (selection[1] is not Outlook.MailItem mail) return false;
|
||||||
|
var attachments = mail.Attachments;
|
||||||
|
if (attachments == null) return false;
|
||||||
|
return attachments.Count > 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnSaveAttachments(IRibbonControl control)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
|
||||||
|
if (selection == null || selection.Count != 1) return;
|
||||||
|
if (selection[1] is Outlook.MailItem mail)
|
||||||
|
{
|
||||||
|
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachments failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
|
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
|
||||||
{
|
{
|
||||||
// Show only on outgoing (compose) mail inspectors.
|
// Show only on outgoing (compose) mail inspectors.
|
||||||
@@ -150,6 +205,7 @@ namespace OutlookAddin.Ribbon
|
|||||||
switch (control.Id)
|
switch (control.Id)
|
||||||
{
|
{
|
||||||
case "FileToEspoCrm": fileName = "klear-file.png"; break;
|
case "FileToEspoCrm": fileName = "klear-file.png"; break;
|
||||||
|
case "SaveAttachments": fileName = "klear-attach.png"; break;
|
||||||
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
|
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
|
||||||
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
|
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
|
||||||
case "ComposeFromCase": fileName = "klear-compose.png"; break;
|
case "ComposeFromCase": fileName = "klear-compose.png"; break;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<ribbon>
|
<ribbon>
|
||||||
<tabs>
|
<tabs>
|
||||||
<tab idMso="TabMail">
|
<tab idMso="TabMail">
|
||||||
<group id="MarcusLawGroup" label="Marcus-Law" insertBeforeMso="GroupMailMove">
|
<group id="MarcusLawGroup" label="Klear" insertBeforeMso="GroupMailMove">
|
||||||
<button id="FileToEspoCrm"
|
<button id="FileToEspoCrm"
|
||||||
label="Klear"
|
label="Klear"
|
||||||
screentip="תייק את המייל הנבחר ל-Klear"
|
screentip="תייק את המייל הנבחר ל-Klear"
|
||||||
@@ -12,6 +12,14 @@
|
|||||||
getImage="OnGetImage"
|
getImage="OnGetImage"
|
||||||
onAction="OnFileToEspoCrm"
|
onAction="OnFileToEspoCrm"
|
||||||
getEnabled="OnGetFileToEspoCrmEnabled" />
|
getEnabled="OnGetFileToEspoCrmEnabled" />
|
||||||
|
<button id="SaveAttachments"
|
||||||
|
label="שמור קבצים"
|
||||||
|
screentip="שמור את הקבצים המצורפים בלבד"
|
||||||
|
supertip="מעלה את הקבצים המצורפים של המייל הנבחר ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
|
||||||
|
size="large"
|
||||||
|
getImage="OnGetImage"
|
||||||
|
onAction="OnSaveAttachments"
|
||||||
|
getEnabled="OnGetSaveAttachmentsEnabled" />
|
||||||
<button id="ShowSidebar"
|
<button id="ShowSidebar"
|
||||||
label="פרטי תיק"
|
label="פרטי תיק"
|
||||||
screentip="הצג את סרגל פרטי התיק"
|
screentip="הצג את סרגל פרטי התיק"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<ribbon>
|
<ribbon>
|
||||||
<tabs>
|
<tabs>
|
||||||
<tab idMso="TabNewMailMessage">
|
<tab idMso="TabNewMailMessage">
|
||||||
<group id="MarcusLawComposeGroup" label="Marcus-Law" insertBeforeMso="GroupClipboard">
|
<group id="MarcusLawComposeGroup" label="Klear" insertBeforeMso="GroupClipboard">
|
||||||
<button id="ComposeFromCase"
|
<button id="ComposeFromCase"
|
||||||
label="כתוב מתיק"
|
label="כתוב מתיק"
|
||||||
screentip="פתח את המייל מתיק Klear"
|
screentip="פתח את המייל מתיק Klear"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace OutlookAddin.Services
|
|||||||
public IEspoCrmClient? CrmClient { get; private set; }
|
public IEspoCrmClient? CrmClient { get; private set; }
|
||||||
public IFilingService? FilingService { get; private set; }
|
public IFilingService? FilingService { get; private set; }
|
||||||
public IMatchingService? MatchingService { get; private set; }
|
public IMatchingService? MatchingService { get; private set; }
|
||||||
|
public IAttachmentSaveService? AttachmentSaveService { get; private set; }
|
||||||
public ComposeService? ComposeService { get; private set; }
|
public ComposeService? ComposeService { get; private set; }
|
||||||
|
|
||||||
private readonly Outlook.Application _outlookApp;
|
private readonly Outlook.Application _outlookApp;
|
||||||
@@ -353,6 +354,7 @@ namespace OutlookAddin.Services
|
|||||||
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
||||||
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
||||||
ComposeService = new ComposeService(CrmClient, Logger);
|
ComposeService = new ComposeService(CrmClient, Logger);
|
||||||
|
AttachmentSaveService = new AttachmentSaveService(CrmClient, Logger);
|
||||||
_folderResolver ??= new FolderResolver(_outlookApp, Logger);
|
_folderResolver ??= new FolderResolver(_outlookApp, Logger);
|
||||||
_folderWatcher = new FolderWatcher(
|
_folderWatcher = new FolderWatcher(
|
||||||
_outlookApp,
|
_outlookApp,
|
||||||
@@ -387,6 +389,7 @@ namespace OutlookAddin.Services
|
|||||||
FilingService = null;
|
FilingService = null;
|
||||||
MatchingService = null;
|
MatchingService = null;
|
||||||
ComposeService = null;
|
ComposeService = null;
|
||||||
|
AttachmentSaveService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -401,7 +404,7 @@ namespace OutlookAddin.Services
|
|||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
|
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Information);
|
MessageBoxImage.Information);
|
||||||
return;
|
return;
|
||||||
@@ -422,12 +425,142 @@ namespace OutlookAddin.Services
|
|||||||
Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
|
Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"טעינת התיק נכשלה: " + ex.Message,
|
"טעינת התיק נכשלה: " + ex.Message,
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Error);
|
MessageBoxImage.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save-attachments-only flow. The user clicks the ribbon's "שמור
|
||||||
|
/// קבצים" button while standing on a mail with one or more
|
||||||
|
/// attachments. We extract the attachments (no Email entity
|
||||||
|
/// created), let the user pick a Case + optional DocumentFolder,
|
||||||
|
/// then upload each attachment + create a Document linked to the
|
||||||
|
/// case.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LaunchSaveAttachmentsAsync(Outlook.MailItem mail)
|
||||||
|
{
|
||||||
|
if (mail == null) return;
|
||||||
|
if (!IsConfigured)
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
|
||||||
|
"Klear",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
if (result == MessageBoxResult.Yes) LaunchSettings();
|
||||||
|
if (!IsConfigured) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract on the STA — MailItemExtractor handles all COM access
|
||||||
|
// and gives us a self-contained envelope.
|
||||||
|
MailEnvelope envelope;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
envelope = MailItemExtractor.Extract(mail);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
|
||||||
|
MessageBox.Show(
|
||||||
|
"קריאת הקבצים המצורפים נכשלה: " + ex.Message,
|
||||||
|
"Klear",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Attachments.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"אין במייל הזה קבצים מצורפים לשמירה.",
|
||||||
|
"Klear",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
|
||||||
|
// Subfolders are populated lazily once the user picks a case;
|
||||||
|
// the default static set is already pre-loaded.
|
||||||
|
|
||||||
|
var dialog = new SaveAttachmentsDialog(vm);
|
||||||
|
AttachOwnerToOutlook(dialog);
|
||||||
|
var ok = dialog.ShowDialog();
|
||||||
|
if (ok != true) return;
|
||||||
|
|
||||||
|
var picked = vm.PickedCase;
|
||||||
|
var selected = vm.SelectedAttachments;
|
||||||
|
if (picked == null || string.IsNullOrEmpty(picked.Id) || selected.Count == 0) return;
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
|
||||||
|
"Klear",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var subfolder = vm.ChosenSubfolderName;
|
||||||
|
var targetFolderPath = string.IsNullOrEmpty(subfolder)
|
||||||
|
? caseFolder
|
||||||
|
: caseFolder + "/" + subfolder;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Logger.Information(
|
||||||
|
"Saving {Count} attachment(s) to Case/{Id} ({Name}) → {Path}",
|
||||||
|
selected.Count, picked.Id, picked.Name, targetFolderPath);
|
||||||
|
|
||||||
|
// Safety net: ensure the case folder + defaults exist on
|
||||||
|
// disk before the per-file uploads (the dialog may have
|
||||||
|
// been submitted before the prior ensure call finished).
|
||||||
|
try { await CrmClient!.EnsureCaseSubfoldersAsync(picked.Id).ConfigureAwait(true); }
|
||||||
|
catch (Exception ex) { Logger.Warning(ex, "Pre-upload ensure failed; continuing anyway"); }
|
||||||
|
|
||||||
|
var summary = await AttachmentSaveService!.SaveAsync(selected, targetFolderPath).ConfigureAwait(true);
|
||||||
|
ShowAttachmentSummary(summary, picked.Name ?? picked.Id, targetFolderPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
|
||||||
|
MessageBox.Show(
|
||||||
|
"שמירת הקבצים נכשלה: " + ex.Message,
|
||||||
|
"Klear",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
|
||||||
|
{
|
||||||
|
string message;
|
||||||
|
MessageBoxImage icon;
|
||||||
|
if (summary.AuthRequired)
|
||||||
|
{
|
||||||
|
message = "מפתח ה-API נדחה. יש לעדכן את ההגדרות ולנסות שוב.";
|
||||||
|
icon = MessageBoxImage.Warning;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (summary.SavedCount > 0) parts.Add($"נשמרו {summary.SavedCount} קבצים אל \"{caseLabel}\" ({targetPath})");
|
||||||
|
if (summary.FailedCount > 0) parts.Add($"{summary.FailedCount} נכשלו");
|
||||||
|
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
|
||||||
|
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
|
||||||
|
}
|
||||||
|
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
|
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
|
||||||
/// Creates a brand new MailItem, populates it with case details, then
|
/// Creates a brand new MailItem, populates it with case details, then
|
||||||
@@ -439,7 +572,7 @@ namespace OutlookAddin.Services
|
|||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
|
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Information);
|
MessageBoxImage.Information);
|
||||||
return;
|
return;
|
||||||
@@ -458,7 +591,7 @@ namespace OutlookAddin.Services
|
|||||||
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
|
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"פתיחת המייל מתיק נכשלה: " + ex.Message,
|
"פתיחת המייל מתיק נכשלה: " + ex.Message,
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Error);
|
MessageBoxImage.Error);
|
||||||
}
|
}
|
||||||
@@ -510,13 +643,20 @@ namespace OutlookAddin.Services
|
|||||||
/// current Explorer selection, opens the search dialog, files on success,
|
/// current Explorer selection, opens the search dialog, files on success,
|
||||||
/// and stamps categories on the chosen MailItems.
|
/// and stamps categories on the chosen MailItems.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task LaunchFileToCaseAsync()
|
public Task LaunchFileToCaseAsync() => LaunchFileToCaseAsync(null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same as <see cref="LaunchFileToCaseAsync()"/>, but when the sidebar
|
||||||
|
/// already resolved a single linked case for the sender we skip the
|
||||||
|
/// search dialog and file directly to that case.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LaunchFileToCaseAsync(MatchResult? preselectedMatch)
|
||||||
{
|
{
|
||||||
if (!IsConfigured)
|
if (!IsConfigured)
|
||||||
{
|
{
|
||||||
var result = MessageBox.Show(
|
var result = MessageBox.Show(
|
||||||
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
|
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
MessageBoxButton.YesNo,
|
MessageBoxButton.YesNo,
|
||||||
MessageBoxImage.Information);
|
MessageBoxImage.Information);
|
||||||
if (result == MessageBoxResult.Yes)
|
if (result == MessageBoxResult.Yes)
|
||||||
@@ -531,7 +671,7 @@ namespace OutlookAddin.Services
|
|||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
|
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Information);
|
MessageBoxImage.Information);
|
||||||
return;
|
return;
|
||||||
@@ -540,39 +680,50 @@ namespace OutlookAddin.Services
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var envelopes = mailItems.Select(MailItemExtractor.Extract).ToList();
|
var envelopes = mailItems.Select(MailItemExtractor.Extract).ToList();
|
||||||
var firstEnvelope = envelopes[0];
|
|
||||||
|
|
||||||
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
|
FilingTarget? target = TryResolveSidebarAutoTarget(preselectedMatch);
|
||||||
|
if (target != null)
|
||||||
{
|
{
|
||||||
SenderEmail = firstEnvelope.From,
|
Logger.Information(
|
||||||
SenderName = TryGetSenderName(mailItems[0])
|
"Auto-filing {Count} mail item(s) from sidebar to {Type}/{Id} ({Name})",
|
||||||
};
|
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
|
||||||
|
|
||||||
var dialog = new FileToCaseDialog(vm);
|
|
||||||
AttachOwnerToOutlook(dialog);
|
|
||||||
var ok = dialog.ShowDialog();
|
|
||||||
Logger.Information(
|
|
||||||
"FileToCase dialog closed (ok={Ok}, selected id={Id}, entityType={Type})",
|
|
||||||
ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType);
|
|
||||||
if (ok != true) return;
|
|
||||||
|
|
||||||
var target = vm.GetSelectedFilingTarget();
|
|
||||||
if (target == null)
|
|
||||||
{
|
|
||||||
Logger.Warning(
|
|
||||||
"Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})",
|
|
||||||
vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType);
|
|
||||||
MessageBox.Show(
|
|
||||||
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
|
|
||||||
"Marcus-Law OutlookAddin",
|
|
||||||
MessageBoxButton.OK,
|
|
||||||
MessageBoxImage.Warning);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var firstEnvelope = envelopes[0];
|
||||||
|
|
||||||
Logger.Information(
|
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
|
||||||
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
|
{
|
||||||
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
|
SenderEmail = firstEnvelope.From,
|
||||||
|
SenderName = TryGetSenderName(mailItems[0])
|
||||||
|
};
|
||||||
|
|
||||||
|
var dialog = new FileToCaseDialog(vm);
|
||||||
|
AttachOwnerToOutlook(dialog);
|
||||||
|
var ok = dialog.ShowDialog();
|
||||||
|
Logger.Information(
|
||||||
|
"FileToCase dialog closed (ok={Ok}, selected id={Id}, entityType={Type})",
|
||||||
|
ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType);
|
||||||
|
if (ok != true) return;
|
||||||
|
|
||||||
|
target = vm.GetSelectedFilingTarget();
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
Logger.Warning(
|
||||||
|
"Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})",
|
||||||
|
vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType);
|
||||||
|
MessageBox.Show(
|
||||||
|
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
|
||||||
|
"Klear",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Information(
|
||||||
|
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
|
||||||
|
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
|
||||||
|
}
|
||||||
|
|
||||||
var summary = await FilingService!.FileAsync(envelopes, target).ConfigureAwait(true);
|
var summary = await FilingService!.FileAsync(envelopes, target).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -589,6 +740,21 @@ namespace OutlookAddin.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar's "תייק לתיק הזה" makes a hard promise about which case
|
||||||
|
// gets the mail — only honour it when the match resolved a single
|
||||||
|
// linked case. With 0 or 2+ cases there is no unambiguous "this",
|
||||||
|
// and the caller falls back to the manual search dialog.
|
||||||
|
private static FilingTarget? TryResolveSidebarAutoTarget(MatchResult? match)
|
||||||
|
{
|
||||||
|
if (match?.RecentCases == null || match.RecentCases.Count != 1) return null;
|
||||||
|
var c = match.RecentCases[0];
|
||||||
|
if (string.IsNullOrWhiteSpace(c.Id)) return null;
|
||||||
|
var label = !string.IsNullOrWhiteSpace(c.Number)
|
||||||
|
? $"[{c.Number}] {c.Name}"
|
||||||
|
: (c.Name ?? c.Id);
|
||||||
|
return new FilingTarget("Case", c.Id, label);
|
||||||
|
}
|
||||||
|
|
||||||
private List<Outlook.MailItem> ReadCurrentSelection()
|
private List<Outlook.MailItem> ReadCurrentSelection()
|
||||||
{
|
{
|
||||||
var result = new List<Outlook.MailItem>();
|
var result = new List<Outlook.MailItem>();
|
||||||
@@ -741,7 +907,7 @@ namespace OutlookAddin.Services
|
|||||||
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
|
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageBox.Show(message, "Marcus-Law OutlookAddin", MessageBoxButton.OK, icon);
|
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? TryGetSenderName(Outlook.MailItem item)
|
private static string? TryGetSenderName(Outlook.MailItem item)
|
||||||
|
|||||||
@@ -98,9 +98,11 @@ namespace OutlookAddin.Services
|
|||||||
{
|
{
|
||||||
att.SaveAsFile(tempPath);
|
att.SaveAsFile(tempPath);
|
||||||
var bytes = File.ReadAllBytes(tempPath);
|
var bytes = File.ReadAllBytes(tempPath);
|
||||||
|
var name = att.FileName ?? "attachment.bin";
|
||||||
env.Attachments.Add(new EspoAttachment
|
env.Attachments.Add(new EspoAttachment
|
||||||
{
|
{
|
||||||
Name = att.FileName ?? "attachment.bin",
|
Name = name,
|
||||||
|
ContentType = GuessMimeType(name),
|
||||||
ContentBase64 = Convert.ToBase64String(bytes)
|
ContentBase64 = Convert.ToBase64String(bytes)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -171,6 +173,70 @@ namespace OutlookAddin.Services
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks a sane MIME type from the file extension. EspoCRM's default
|
||||||
|
/// attachment-upload config rejects "application/octet-stream" with
|
||||||
|
/// "Not allowed file type", so we map common Office / image / archive
|
||||||
|
/// extensions to canonical MIMEs before posting.
|
||||||
|
/// </summary>
|
||||||
|
private static string GuessMimeType(string fileName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName)) return "application/octet-stream";
|
||||||
|
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||||
|
switch (ext)
|
||||||
|
{
|
||||||
|
// Office (legacy + OOXML)
|
||||||
|
case ".pdf": return "application/pdf";
|
||||||
|
case ".doc": return "application/msword";
|
||||||
|
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
case ".xls": return "application/vnd.ms-excel";
|
||||||
|
case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
case ".ppt": return "application/vnd.ms-powerpoint";
|
||||||
|
case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
case ".rtf": return "application/rtf";
|
||||||
|
case ".odt": return "application/vnd.oasis.opendocument.text";
|
||||||
|
case ".ods": return "application/vnd.oasis.opendocument.spreadsheet";
|
||||||
|
|
||||||
|
// Text
|
||||||
|
case ".txt": return "text/plain";
|
||||||
|
case ".csv": return "text/csv";
|
||||||
|
case ".log": return "text/plain";
|
||||||
|
case ".htm":
|
||||||
|
case ".html": return "text/html";
|
||||||
|
case ".xml": return "application/xml";
|
||||||
|
case ".json": return "application/json";
|
||||||
|
|
||||||
|
// Images
|
||||||
|
case ".png": return "image/png";
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg": return "image/jpeg";
|
||||||
|
case ".gif": return "image/gif";
|
||||||
|
case ".bmp": return "image/bmp";
|
||||||
|
case ".tif":
|
||||||
|
case ".tiff": return "image/tiff";
|
||||||
|
case ".webp": return "image/webp";
|
||||||
|
case ".svg": return "image/svg+xml";
|
||||||
|
|
||||||
|
// Archives
|
||||||
|
case ".zip": return "application/zip";
|
||||||
|
case ".rar": return "application/x-rar-compressed";
|
||||||
|
case ".7z": return "application/x-7z-compressed";
|
||||||
|
case ".gz": return "application/gzip";
|
||||||
|
case ".tar": return "application/x-tar";
|
||||||
|
|
||||||
|
// Email
|
||||||
|
case ".eml": return "message/rfc822";
|
||||||
|
case ".msg": return "application/vnd.ms-outlook";
|
||||||
|
|
||||||
|
// Audio / video — rare in legal mail but cheap to handle
|
||||||
|
case ".mp3": return "audio/mpeg";
|
||||||
|
case ".wav": return "audio/wav";
|
||||||
|
case ".mp4": return "video/mp4";
|
||||||
|
|
||||||
|
default: return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static T? SafeGet<T>(Func<T> getter) where T : class
|
private static T? SafeGet<T>(Func<T> getter) where T : class
|
||||||
{
|
{
|
||||||
try { return getter(); } catch { return null; }
|
try { return getter(); } catch { return null; }
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ namespace OutlookAddin.Services
|
|||||||
|
|
||||||
vm.FileRequested += async (s, e) =>
|
vm.FileRequested += async (s, e) =>
|
||||||
{
|
{
|
||||||
try { await _host.LaunchFileToCaseAsync().ConfigureAwait(true); }
|
try { await _host.LaunchFileToCaseAsync(e.PreselectedMatch).ConfigureAwait(true); }
|
||||||
catch (Exception ex) { _logger.Warning(ex, "Sidebar file-request handler failed"); }
|
catch (Exception ex) { _logger.Warning(ex, "Sidebar file-request handler failed"); }
|
||||||
};
|
};
|
||||||
vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url);
|
vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ namespace OutlookAddin
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
System.Windows.MessageBox.Show(
|
System.Windows.MessageBox.Show(
|
||||||
"האתחול של תוסף Marcus-Law נכשל: " + ex.Message,
|
"האתחול של תוסף Klear נכשל: " + ex.Message,
|
||||||
"Marcus-Law OutlookAddin",
|
"Klear",
|
||||||
System.Windows.MessageBoxButton.OK,
|
System.Windows.MessageBoxButton.OK,
|
||||||
System.Windows.MessageBoxImage.Error);
|
System.Windows.MessageBoxImage.Error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user