Compare commits

...

11 Commits

Author SHA1 Message Date
PointStar 62254fcd8c publish: 1.2.2 — verify auto-update path
No code change. Bumping to confirm a fresh install from platform.dev
picks up the next tag-driven release cleanly via "בדוק עדכונים".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:49:56 +03:00
PointStar 7aa96c99a3 feat(about): logs + settings paths open the folder in Explorer
The two %APPDATA% / %LOCALAPPDATA% paths in Settings → אודות are now
hyperlinks. Click to open the folder (creates it lazily if it's not
there yet, so the link works before the first log is written).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:43:24 +03:00
PointStar 35455c2ef6 docs(briefing): document the live release pipeline + Klear rebrand boundary
Replaces the stale "Things deliberately deferred" entries (CI runner,
codesign cert, platform.dev hosting, Mattermost webhook) with a new
"How releases work (current)" section that captures what's actually
running today:

- Production install URL = platform.dev.marcus-law.co.il/outlook-addin/
- Tag-driven Gitea Actions pipeline in .gitea/workflows/build.yml
- Real codesign cert lives in Gitea secrets, dev cert in repo
- Klear visible rebrand boundary: what shipped in v1.2.0 vs what stays
  MarcusLaw to preserve auto-update across the 10 installed lawyer PCs
- The dropped Publish/ folder was never the production channel

The "deferred" table now only mentions the identity-level Layer-2
rebrand, which is the only thing actually still open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:35:51 +03:00
PointStar 025dab9b20 feat(branding): Klear visual rebrand + drop orphaned Publish/ dir
User-facing strings only — ClickOnce identity, AssemblyName, namespaces,
DPAPI entropy and on-disk paths all stay MarcusLaw so the 10 lawyers
auto-update without any manual uninstall/reinstall.

What lawyers see change:
- About panel: dark-navy header card (#121d2e from KlearBranding) with
  the Klear logo (32KB PNG mirrored from
  espocrm-extensions/KlearBranding/files/client/custom/img/logo-klear.png)
  and the title "Klear" instead of "Marcus-Law OutlookAddin"
- All MessageBox titles ("Klear" instead of "Marcus-Law OutlookAddin")
- Ribbon group label ("Klear" instead of "Marcus-Law") in both the
  Explorer and the new-message Inspector ribbons
- Startup-failure dialog ("תוסף Klear נכשל" instead of "תוסף Marcus-Law")

What stays MarcusLaw (deliberate — Layer 1 only):
- ClickOnce app identity → preserves auto-update across this release
- %APPDATA%\MarcusLaw\…\settings.json + creds.dat → no migration needed
- DpapiCredentialStore EntropySeed → API keys remain decryptable
- Internal namespaces / pipe name / AssemblyCompany attribute

Cleanup:
- Removed the entire Publish/ directory from git. It was the orphaned
  manual-release channel under gitea raw/branch/main/Publish/ that no
  one actually uses — production installs are served from
  platform.dev.marcus-law.co.il via the Gitea Actions pipeline. The
  pipeline writes to a local publish/ workspace and uploads a tar.gz
  to the generic registry; it never reads or writes the repo's Publish/.
- Wiped the local LegalCRM_1_0_*_0 leftovers from the failed rename
  experiment.

Bumped to 1.2.0 — MINOR because the rebrand is a user-visible change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:32:40 +03:00
PointStar b1dc57324e fix(save-attachments): authoritative disk-verify + hide extension in rename UI
Two bugs reported after v1.1.2:

1. False-negative "נכשלו" toast even though the file was on disk. Root
   cause from the log: Polly's 15s timeout on /NetworkStorage/action/upload
   fires while the server is still writing larger PDFs, so the client sees
   TimeoutRejectedException → Outcome.Failed, while the server quietly
   finishes the write. The outcome is now decided by listing the target
   folder, not by the upload response. If the upload exception fires, the
   listing is retried up to 4 times with 2s delays to let the server
   finish. Saved if the file appears, Failed only if it never does.

2. The filename TextBox in the attachments list exposed the extension
   (.pdf / .docx). Users have to delete it manually when renaming and
   risk dropping it. AttachmentRowViewModel now splits FileName into
   BaseName (editable) and Extension (frozen at construction). The XAML
   binds the TextBox to BaseName and shows the Extension as a muted
   adjacent chip. The on-disk filename = BaseName + Extension always.

Bump to 1.1.3.0 for the patch release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:21:28 +03:00
PointStar 18b6640e57 feat(save-attachments): editable filenames, Hebrew status, smoother subfolder load
- DataGrid case picker now shows status via CaseStatusToHebrewConverter
  (added PendingHearing / PendingDecision mappings)
- Attachment row replaces the read-only filename TextBlock with a TwoWay
  TextBox so users can rename a file before saving (flows through
  EspoAttachment.Name into the upload + the on-disk path)
- SaveAttachmentsViewModel.IsLoadingSubfolders gates the folder ComboBox
  while EnsureCaseSubfolders / GetCase / ListNetworkStorageFolder run,
  so users can't pick a stale "(שורש התיק)" before the real list arrives
- About tab: ResolveDisplayVersion now reads from the host VSTO assembly
  (*.OutlookAddin) instead of the UI assembly. The UI csproj is SDK-style
  with no <Version> so it always reported 1.0.0.0, which leaked into the
  About line on dev installs
- Bump to 1.1.2.0 (csproj + AssemblyInfo) to match the v1.1.2 tag that
  will trigger the Gitea Actions auto-publish to platform.dev

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:27:49 +03:00
PointStar dbd7f3068b publish: 1.0.7 — switch to 3-part SemVer, manual bumps only
Versioning policy: MAJOR.MINOR.PATCH stored as MAJOR.MINOR.PATCH.0
in the ClickOnce manifest (it requires 4 components). The trailing
.0 is hidden by SettingsViewModel.ResolveDisplayVersion so the About
tab shows "1.0.7" not "1.0.7.0".

AutoIncrementApplicationRevision is now false; every published change
must bump <ApplicationVersion> and AssemblyVersion/AssemblyFileVersion
together by hand. MAJOR bumps need explicit owner approval.

Previously AssemblyVersion was pinned at 1.0.0.0 while ClickOnce
auto-bumped only ApplicationVersion, so the deployed DLL's
FileVersion never matched the manifest. Now they're locked at 1.0.7.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:51:40 +03:00
PointStar b6624a924f feat(save-attachments): show case#, court#, status in candidate picker
Replace the single-line ListBox with a DataGrid (case number, name,
court case number, status) so users disambiguate same-named cases
(e.g. two "שלומוב זויה" cases). Hydrates each search hit via
GET /Case/{id}?select=id,name,number,status,cCourtCaseNumber in
parallel so columns populate without an extra click. CourtCaseNumber
is mapped to the EspoCRM custom field cCourtCaseNumber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:51:15 +03:00
PointStar e3fb0e95d1 fix(publish): stop git CRLF/LF normalization on ClickOnce bundle
The install failed with "the file MarcusLaw.OutlookAddin.dll.config has
a computed hash different from the one specified in the manifest" because
git's core.autocrlf=true normalized the dll.config.deploy file from CRLF
(as VS wrote it) to LF in the index. End users downloading via setup.exe
from the Gitea raw URL got the LF version, whose SHA-1 differs from the
hash recorded in dll.manifest at publish time.

Add a .gitattributes that:
  - marks Publish/** as -text (no normalization, ever)
  - belt-and-braces marks *.deploy / *.manifest / *.vsto / *.application
    / *.pfx as binary globally

git add --renormalize re-staged dll.config.deploy with its real CRLF
bytes, so the hash in the manifest now matches the file on Gitea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:14:58 +03:00
PointStar 9e7a64045f publish: 1.0.0.5 — host ClickOnce bundle on Gitea raw URL
- InstallUrl switched from \192.168.10.97 LAN share to
  https://gitea.dev.marcus-law.co.il/.../raw/branch/main/Publish/
  so end-users (including remote ones over VPN) can install and
  auto-update from the Gitea repo over HTTPS.
- IsWebBootstrapper set to True now that setup.exe fetches over HTTP.
- .gitignore: re-include Publish/** despite the *.vsto/*.application/
  *.deploy/*.manifest wildcards so the ClickOnce distribution root
  can be tracked.
- First publish of the bundle (version 1.0.0.5); older 1_0_0_2..4
  folders deleted before commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:58:10 +03:00
PointStar 017f10e60b feat(sidebar): inline candidate picker, per-case file action, back navigation
- Ambiguous lookups (2+ contacts on one email) now dedupe by Contact Id
  and fetch each candidate's full record so the picker shows
  name + account + email instead of just the bare search payload.
- Per-row "תייק" button on each linked case lets the user pick the
  correct case to file to when a contact has multiple cases; bottom
  "תייק לתיק הזה" button is hidden in that scenario.
- "→ חזור לרשימת אנשי קשר" link restores the candidate list after
  drilling into one of them.
- Contact name + case rows in the sidebar are now hyperlinks that
  open the entity in Klear.
- AddInHost.LaunchFileToCaseAsync accepts an optional MatchResult and
  auto-files when the sidebar resolved a single linked case, skipping
  the manual search dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:57:51 +03:00
26 changed files with 838 additions and 238 deletions
+15
View File
@@ -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
View File
@@ -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
+37 -4
View File
@@ -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 |
--- ---
@@ -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; }
@@ -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;
} }
} }
@@ -46,35 +46,17 @@ namespace MarcusLaw.OutlookAddin.Core.Services
continue; 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 try
{ {
// Step 1: create the EspoCRM Attachment (binary stored
// in data/upload/<id>; not yet on the network share).
var uploaded = await _client.UploadAttachmentAsync( var uploaded = await _client.UploadAttachmentAsync(
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false); att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
attachmentId = uploaded.Id;
// Step 2: ask Marcus-Law's NetworkStorageIntegration
// to place the uploaded blob under <targetFolderPath>.
// The integration appends the filename to the path.
await _client.UploadAttachmentToNetworkStorageAsync(
uploaded.Id, trimmedFolder, cancellationToken).ConfigureAwait(false);
// Step 3: verify the file actually landed on the share —
// the server can return 200 yet leave nothing on disk
// when a downstream WebDAV proxy silently fails. Read
// the folder back and look for our filename.
await VerifyArrivedAsync(att.Name, trimmedFolder, cancellationToken).ConfigureAwait(false);
_logger.Information(
"Saved attachment {Name} → {Path} (attachmentId={AttachmentId})",
att.Name, trimmedFolder, uploaded.Id);
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.Saved,
AttachmentId = uploaded.Id
});
} }
catch (EspoCrmAuthorizationException ex) catch (EspoCrmAuthorizationException ex)
{ {
@@ -86,20 +68,85 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Outcome = AttachmentSaveOutcome.AuthRequired, Outcome = AttachmentSaveOutcome.AuthRequired,
ErrorMessage = ex.Message ErrorMessage = ex.Message
}); });
continue;
} }
catch (OperationCanceledException) catch (OperationCanceledException) { throw; }
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.Warning(ex, "Attachment save failed for {Name}", att.Name); // 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 summary.Items.Add(new AttachmentSaveItemResult
{ {
FileName = att.Name, FileName = att.Name,
Outcome = AttachmentSaveOutcome.Failed, Outcome = AttachmentSaveOutcome.Failed,
ErrorMessage = ex.Message 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
});
} }
} }
@@ -107,51 +154,60 @@ namespace MarcusLaw.OutlookAddin.Core.Services
} }
/// <summary> /// <summary>
/// Post-upload sanity check: list the folder we just wrote into /// Authoritative post-upload check. Returns true if the file is on
/// and confirm a file with the same (possibly sanitized) name /// disk under <paramref name="folderPath"/>. When the upload call
/// shows up. Diagnostic only — does not change the outcome. If /// raised (typically Polly's 15s timeout on a still-writing server),
/// the file isn't there, log a WARNING with everything that IS /// we retry the listing a few times so the server gets a chance
/// in the folder so we can spot the actual server-side path. /// to finish.
/// </summary> /// </summary>
private async Task VerifyArrivedAsync(string expectedName, string folderPath, CancellationToken ct) 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 try
{ {
var items = await _client.ListNetworkStorageFolderAsync(folderPath, ct).ConfigureAwait(false); var items = await _client.ListNetworkStorageFolderAsync(folderPath, ct).ConfigureAwait(false);
var trimmedExpected = expectedName.Trim().TrimStart('-', ' ').TrimEnd('-', ' '); var trimmedExpected = expectedName.Trim().TrimStart('-', ' ').TrimEnd('-', ' ');
bool found = false;
foreach (var it in items) foreach (var it in items)
{ {
if (it.IsFolder) continue; if (it.IsFolder) continue;
if (string.Equals(it.Name, expectedName, StringComparison.Ordinal) || if (string.Equals(it.Name, expectedName, StringComparison.Ordinal) ||
string.Equals(it.Name, trimmedExpected, StringComparison.Ordinal)) string.Equals(it.Name, trimmedExpected, StringComparison.Ordinal))
{ {
found = true; return true;
break;
} }
} }
if (!found) return false;
{
var names = new List<string>();
foreach (var it in items)
{
if (it.IsFolder) continue;
names.Add(it.Name);
}
_logger.Warning(
"Post-upload verify: expected '{Expected}' under '{Path}' but it's NOT in the listing. " +
"Server reported the upload as successful but the file is not on the share. " +
"Files currently in that folder: [{Files}]",
expectedName, folderPath, string.Join(", ", names));
}
else
{
_logger.Information("Post-upload verify: '{Expected}' present under '{Path}'", expectedName, folderPath);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Warning(ex, "Post-upload verify failed for '{Expected}' under '{Path}'", expectedName, folderPath); _logger.Warning(ex, "Post-upload listing under '{Path}' failed", folderPath);
return false;
} }
} }
} }
@@ -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);
} }
} }
+116 -44
View File
@@ -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"] = "פתוח",
@@ -1,6 +1,7 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.SaveAttachmentsDialog" <Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.SaveAttachmentsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="שמור קבצים מצורפים ל-Klear" Title="שמור קבצים מצורפים ל-Klear"
Height="560" Width="620" Height="560" Width="620"
MinHeight="420" MinWidth="500" MinHeight="420" MinWidth="500"
@@ -11,6 +12,9 @@
Background="#F8FAFC" Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial"> FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources> <Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" /> <SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" /> <SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" /> <SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -80,32 +84,28 @@
</Setter> </Setter>
</Style> </Style>
<Style x:Key="CaseItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4"
Margin="2,1" Padding="8,6">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBg}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource SelectedBg}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources> </Window.Resources>
<Grid Margin="16"> <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> <Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
@@ -130,32 +130,38 @@
<!-- Cases --> <!-- Cases -->
<Border Grid.Row="1" Style="{StaticResource Card}"> <Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}" <DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}" SelectedItem="{Binding SelectedCase}"
BorderThickness="0" Style="{StaticResource CaseGrid}">
Background="Transparent" <DataGrid.Columns>
ItemContainerStyle="{StaticResource CaseItem}" <DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
Padding="4"> <DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
<ListBox.ItemTemplate> <DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
<DataTemplate> <DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
<StackPanel> </DataGrid.Columns>
<TextBlock FontSize="13"> </DataGrid>
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border> </Border>
<!-- Folder --> <!-- Folder -->
<DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True"> <DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center" <TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center"
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" /> 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}" <ComboBox ItemsSource="{Binding Subfolders}"
SelectedItem="{Binding SelectedSubfolder}" SelectedItem="{Binding SelectedSubfolder}"
Padding="6,4" /> 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> </DockPanel>
<!-- Attachments header --> <!-- Attachments header -->
@@ -178,10 +184,20 @@
FontSize="11" FontSize="11"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="8,0,0,0" /> Margin="8,0,0,0" />
<TextBlock Text="{Binding FileName}" <TextBlock Text="{Binding Extension}"
DockPanel.Dock="Left"
Foreground="{StaticResource Muted}"
FontSize="12"
VerticalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Margin="6,0,0,0" />
Margin="8,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> </DockPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -98,8 +98,19 @@
<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">
<Image DockPanel.Dock="Right"
Source="pack://application:,,,/MarcusLaw.OutlookAddin.UI;component/Resources/logo-klear.png"
Height="36"
Margin="12,0,0,0"
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"> <DockPanel LastChildFill="False" Margin="0,0,0,4">
<TextBlock DockPanel.Dock="Left" Text="{Binding VersionLine}" Foreground="Gray" VerticalAlignment="Center" /> <TextBlock DockPanel.Dock="Left" Text="{Binding VersionLine}" Foreground="Gray" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right" <Button DockPanel.Dock="Right"
@@ -112,10 +123,22 @@
Foreground="{Binding UpdateStatusBrush}" Foreground="{Binding UpdateStatusBrush}"
TextWrapping="Wrap" TextWrapping="Wrap"
Margin="0,4,0,0" /> Margin="0,4,0,0" />
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap" <TextBlock Margin="0,16,0,0" TextWrapping="Wrap">
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" /> <Run Text="לוגים: " />
<TextBlock TextWrapping="Wrap" <Hyperlink Tag="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs"
Text="הגדרות: %APPDATA%\MarcusLaw\OutlookAddin\settings.json" /> 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);
}
}
} }
} }
@@ -25,4 +25,8 @@
<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

@@ -1,3 +1,4 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Models; using MarcusLaw.OutlookAddin.Core.Models;
@@ -10,6 +11,34 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
[ObservableProperty] [ObservableProperty]
private bool isSelected = true; 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 &lt;basename&gt;&lt;Extension&gt;.
/// </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 FileName => Data.Name;
public string SizeDisplay { get; } public string SizeDisplay { get; }
@@ -17,6 +46,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public AttachmentRowViewModel(EspoAttachment data) public AttachmentRowViewModel(EspoAttachment data)
{ {
Data = data; Data = data;
Extension = Path.GetExtension(data.Name) ?? string.Empty;
SizeDisplay = ComputeSize(data.ContentBase64); SizeDisplay = ComputeSize(data.ContentBase64);
} }
@@ -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;
} }
@@ -45,6 +45,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
[ObservableProperty] [ObservableProperty]
private string? statusMessage; private string? statusMessage;
[ObservableProperty]
private bool isLoadingSubfolders;
/// <summary> /// <summary>
/// Resolved server-side case folder path for the currently /// Resolved server-side case folder path for the currently
/// selected case (e.g. "47-אריאל מונאס"). Populated by /// selected case (e.g. "47-אריאל מונאס"). Populated by
@@ -140,6 +143,19 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
var c = SelectedCase; var c = SelectedCase;
if (c == null || string.IsNullOrEmpty(c.Id)) return; 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); } try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); } catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
@@ -237,6 +253,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
return name.Trim(' ', '-'); 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) private async Task SearchAsync(string query, CancellationToken ct)
{ {
try try
@@ -255,20 +292,32 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
Cases.Clear(); Cases.Clear();
int kept = 0; var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List) foreach (var hit in search.List)
{ {
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue; if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
Cases.Add(new CaseEntity caseHits.Add(hit);
{
Id = hit.Id,
Name = hit.Name
});
kept++;
} }
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)" if (caseHits.Count == 0)
: $"נמצאו {kept} תיקים"; {
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 (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException) catch (EspoCrmAuthorizationException)
@@ -91,12 +91,13 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary> /// </summary>
private static string ResolveDisplayVersion() private static string ResolveDisplayVersion()
{ {
Version? v = null;
try try
{ {
if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed) if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{ {
return System.Deployment.Application.ApplicationDeployment v = System.Deployment.Application.ApplicationDeployment
.CurrentDeployment.CurrentVersion.ToString(); .CurrentDeployment.CurrentVersion;
} }
} }
catch catch
@@ -104,7 +105,30 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
// Not running under ClickOnce (dev / F5), or System.Deployment // Not running under ClickOnce (dev / F5), or System.Deployment
// is not available — fall through to the AssemblyVersion. // is not available — fall through to the AssemblyVersion.
} }
return typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0"; // 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>
+69 -10
View File
@@ -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"
+4 -4
View File
@@ -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.5</ApplicationVersion> <ApplicationVersion>1.2.2.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>
+2 -2
View File
@@ -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.2.0")]
[assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.2.2.0")]
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+76 -43
View File
@@ -404,7 +404,7 @@ namespace OutlookAddin.Services
{ {
MessageBox.Show( MessageBox.Show(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.", "התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -425,7 +425,7 @@ 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);
} }
@@ -446,7 +446,7 @@ namespace OutlookAddin.Services
{ {
var result = MessageBox.Show( var result = MessageBox.Show(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?", "התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Information); MessageBoxImage.Information);
if (result == MessageBoxResult.Yes) LaunchSettings(); if (result == MessageBoxResult.Yes) LaunchSettings();
@@ -465,7 +465,7 @@ namespace OutlookAddin.Services
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed"); Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
MessageBox.Show( MessageBox.Show(
"קריאת הקבצים המצורפים נכשלה: " + ex.Message, "קריאת הקבצים המצורפים נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
return; return;
@@ -475,7 +475,7 @@ namespace OutlookAddin.Services
{ {
MessageBox.Show( MessageBox.Show(
"אין במייל הזה קבצים מצורפים לשמירה.", "אין במייל הזה קבצים מצורפים לשמירה.",
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -505,7 +505,7 @@ namespace OutlookAddin.Services
{ {
MessageBox.Show( MessageBox.Show(
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.", "לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
return; return;
@@ -535,7 +535,7 @@ namespace OutlookAddin.Services
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed"); Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
MessageBox.Show( MessageBox.Show(
"שמירת הקבצים נכשלה: " + ex.Message, "שמירת הקבצים נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error); MessageBoxImage.Error);
} }
@@ -558,7 +558,7 @@ namespace OutlookAddin.Services
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts); message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
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);
} }
/// <summary> /// <summary>
@@ -572,7 +572,7 @@ namespace OutlookAddin.Services
{ {
MessageBox.Show( MessageBox.Show(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.", "התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -591,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);
} }
@@ -643,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)
@@ -664,7 +671,7 @@ namespace OutlookAddin.Services
{ {
MessageBox.Show( MessageBox.Show(
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.", "לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
"Marcus-Law OutlookAddin", "Klear",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@@ -673,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);
@@ -722,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>();
@@ -874,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)
@@ -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);
+2 -2
View File
@@ -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);
} }