Compare commits

..

27 Commits

Author SHA1 Message Date
PointStar 6a51a58224 fix(save-attachments): marshal folder-tree mutations to the Dispatcher
v1.2.15's tree picker built the whole tree inside an async method and
mutated the TreeView-bound ObservableCollections after an await. In this
VSTO host the WPF SynchronizationContext is not installed, so
ConfigureAwait(true) resumed on a thread-pool thread and FolderTree.Add
threw "changes to its SourceCollection from a thread different from the
Dispatcher thread" — the exception was swallowed and the tree showed
empty (no folders selectable at all).

Capture the UI Dispatcher in the view-model and marshal every bound
collection mutation (FolderTree + each node's Children) through it, so
the tree populates regardless of which thread the network await resumes
on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:58:53 +03:00
PointStar 9b451c9cbe feat(save-attachments): nested-folder tree picker + top-most message boxes
1.2.15

Folder picker: replace the flat subfolder dropdown with a lazy-loading
TreeView so the user can drill into nested subfolders to any depth.
New FolderNodeViewModel lists each folder's children on first expand;
the case-root node is selected/expanded by default and seeded with the
default subfolders (kept even if the share listing fails). ChosenSubfolderName
now returns a multi-segment relative path, appended to the case folder
as before.

Message boxes: route all of AddInHost's MessageBox calls through a new
ShowMessage helper that anchors a transient top-most owner window to the
foreground Outlook window. A bare MessageBox has no owner once our dialog
closes, so it sank behind Outlook and the user never saw the success/error
result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:35:43 +03:00
PointStar 35ce8facff fix(attachments): bind upload to Note.attachments to satisfy EspoCRM
The EspoCRM tenant now rejects role=Attachment uploads that carry
neither `field` nor `parentType` (400 "No `field` and `parentType`."),
which broke the entire save-attachments flow on v1.2.13 — every file,
including PDFs, failed at Step 1.

Bind the upload to the documented Attachment-Multiple example
parentType=Note, field=attachments. Note.attachments is a built-in
field with no allowed-file-types restriction, so all types pass
(including images) — unlike Document.file, which rejected images with
403. No parentId is set; the blob stays standalone and is still just
pushed to the network share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:28:22 +03:00
PointStar c685f4f91d publish: 1.2.13 — preselect sender case, save images, save-from-open-mail
Three fixes to the save-attachments flow:

1. Preselect the identified client case. When the mail sender already
   maps to a CRM contact, the "שמור קבצים" dialog now seeds its case
   list from the sidebar match (cached lookup) instead of opening empty
   — single linked case auto-selects (and resolves the target folder);
   several show for the user to pick. Search still overrides.

2. Save image attachments. EspoCRM's Document.file field is configured
   to accept document types only, so JPEG/PNG uploads were rejected with
   403 "Not allowed file type". The save-attachments flow never creates a
   Document — it only pushes the blob to the network share — so we now
   POST the Attachment unbound (no relatedType=Document/field=file),
   skipping the per-field accept check and letting every type through.

3. Save from an open message. Adds a "שמור קבצים" button to the read-mail
   inspector ribbon (Microsoft.Outlook.Mail.Read) so the user no longer
   has to return to the message list to file attachments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:49:16 +03:00
PointStar 5eae3da213 publish: 1.2.12 — harden VSTO rename + reissue broken v1.2.11 release
v1.2.11 shipped a broken publish.tar.gz: the Rename-Item that turns
`MarcusLaw.OutlookAddin.vsto` into the canonical `OutlookAddin.vsto`
failed silently (script ran with $ErrorActionPreference = 'Continue'),
so the docker image had no OutlookAddin.vsto at the expected path and
the CDN 404'd on the auto-update URL. Gitea Generic packages are
immutable, so 1.2.11 cannot be republished.

Restrict the .vsto lookup to publish/ root (Get-ChildItem -File, no
-Recurse) so the inner versioned manifest under Application Files is
never picked, switch the rename to -ErrorAction Stop, and assert that
publish/OutlookAddin.vsto exists before continuing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:31:14 +03:00
PointStar 8264c11b57 publish: 1.2.11 — compose-from-case email + picker columns fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:56:32 +03:00
PointStar 4556d2c1b3 fix(compose-from-case): use any contact with email + show number/status
Walk Case.contactsIds for the first contact with a non-empty emailAddress
(falling back to the Account's emailAddress) instead of blindly using
contactsIds[0] and giving up if it has no email — Outlook was opening
the new mail with no To recipient when the first linked contact was
e.g. opposing counsel or a witness without a stored address.

Also hydrate the "כתוב מתיק" picker rows with number/status/court#
through GetCaseAsync (same pattern as SaveAttachmentsViewModel) and
switch the dialog to the DataGrid layout so it matches the file-
attachments picker the user expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:56:18 +03:00
PointStar a8e947c825 docs: reflect v1.2.9 auto-update settings + v1.2.7 button behavior
Updates after the day-long updater overhaul:

- The csproj no longer has <UpdateInterval>7</UpdateInterval>; v1.2.9
  switched to <UpdatePeriodically>false</UpdatePeriodically>, so the
  VSTO runtime checks the .vsto manifest on every Outlook startup.
  Both PROJECT-BRIEFING.md ("How releases work") and
  AUTO-UPDATE-SETUP.md (section 5) now describe the on-startup model
  instead of the weekly-poll model that was never operationally true.
- AUTO-UPDATE-SETUP.md also gets two new notes that came out of today:
  what the "בדוק עדכונים" button can and can't do (informational only,
  pointing at the in-process-update impossibility memo), and the
  one-time uninstall+reinstall required when a developer's machine has
  a dev-cert install that can't auto-update across to the prod cert.

No code change; no tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:14:00 +03:00
PointStar 32f6facb67 publish: 1.2.10 — verify on-every-startup auto-update from v1.2.9
No code change. After v1.2.9 set UpdatePeriodically=false, the VSTO
runtime should hit the .vsto manifest on every Outlook launch. From a
clean v1.2.9 install: close Outlook, reopen, About tab shows 1.2.10
with no clicks anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:53:54 +03:00
PointStar 2228353c48 fix(updater): check for updates on every Outlook startup, not every 7 days
Why v1.2.7→v1.2.8 didn't auto-install on Outlook restart: the manifest
was published with <UpdateInterval>7</UpdateInterval> + units=days, which
tells the VSTO runtime "don't even check the manifest URL more often
than once a week." MinimumRequiredVersion (set by CI to the same tag
version) would have forced the install — but only AFTER the check runs,
and the check was throttled to weekly.

Replace UpdateInterval/UpdateIntervalUnits with UpdatePeriodically=false.
This omits the <expiration> element from the deployment manifest, which
ClickOnce/VSTO interprets as "check before every host start." The extra
HTTP GET on the .vsto manifest at Outlook launch is ~6KB, ~100ms — a
non-issue for startup time, vs. the previous behavior where new
releases sat unused on platform.dev for up to a week per user.

From v1.2.9 onward, tag → CI publishes → next Outlook launch on each
lawyer's PC installs it. This is what we wanted the auto-update chain
to look like all along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:50:31 +03:00
PointStar eea0d92d85 publish: 1.2.8 — verify v1.2.7's restart-to-update flow end-to-end
No code change. Confirms that on a clean v1.2.7 install (correct
simplified updater), pressing "בדוק עדכונים" reports the new version
honestly, and the VSTO runtime auto-installs v1.2.8 on the next
Outlook startup with no errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:58 +03:00
PointStar 655fefa822 fix(updater): stop trying to install in-process; tell user to restart Outlook
v1.2.6 fired VSTOInstaller.exe /I directly. It returned 0x8007007E
(ERROR_MOD_NOT_FOUND) — the loaded add-in DLLs are file-locked by the
running Outlook, so even the canonical installer can't overwrite them
while the host is up. This caps a series of failed attempts:

- 1.2.0/1/2: ApplicationDeployment.Update() → TrustNotGrantedException
- 1.2.3/4:   Process.Start(.vsto URL, shell)  → Chrome downloads to
             local-machine zone → InvalidDeploymentException
- 1.2.5/6:   VSTOInstaller.exe /I <url>       → 0x8007007E file lock

There is no API that updates a running VSTO add-in. The VSTO runtime's
own auto-update path (check manifest on Outlook startup, install before
loading) is the only mechanism that works — and it already does, every
time the user restarts Outlook.

So the button now just detects the new version via manifest HTTP GET
and displays "זמינה גרסה X. סגור ופתח את Outlook — הגרסה החדשה תותקן
אוטומטית בעלייה." No install attempt, no spawned process, no zone or
trust dance. The status line is honest about what the user has to do.

Removed the now-unused FindVstoInstaller registry-walk helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:17:32 +03:00
PointStar d2c54840ba publish: 1.2.6 — smoke-test the direct VSTOInstaller updater
No code change. Confirms that the v1.2.5 updater (VSTOInstaller.exe /I)
bypasses the Chrome download trap and pops the install dialog directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:11:38 +03:00
PointStar 870426ec08 fix(updater): invoke VSTOInstaller.exe directly so Chrome doesn't hijack the .vsto
v1.2.3 changed the updater to Process.Start the .vsto URL with
UseShellExecute=true. On any browser-as-default-handler (Chrome / Edge)
that downloads the file to disk instead of handing it to VSTOInstaller,
and re-opening the local copy then fails with InvalidDeploymentException
"לפריסה וליישום אין אזורי אבטחה תואמים" — the downloaded file lives in
the Local Machine zone while the manifest declares the Internet zone.

The reliable path is to invoke VSTOInstaller.exe directly with /I and
the manifest URL. The URL is then processed in the Internet zone,
matching what's declared in the manifest. FindVstoInstaller() looks at
the canonical VSTO 4.0 path and falls back to the HKCR registration of
the .vsto extension. If even that fails, we tell the user to close and
reopen Outlook — the VSTO runtime picks up the new manifest on its own
during startup, so the worst case is just an extra restart, not a stuck
install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:07:58 +03:00
PointStar 137929b4ac publish: 1.2.4 — smoke-test the new manifest-fetch updater
No code change. Confirms that the v1.2.3 updater (HTTP GET the manifest
+ Process.Start the .vsto URL) reaches VSTOInstaller cleanly without
the TrustNotGrantedException that the old ApplicationDeployment.Update
path always threw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:01:17 +03:00
PointStar 95d7599cb1 fix(updater): replace broken ApplicationDeployment API with manifest GET
Log shows TrustNotGrantedException thrown inside CheckForDetailedUpdate
itself (ApplicationTrust.RequestTrust → DetermineTrustCore →
DetermineTrust). The misleading "המשתמש סירב להעניק את ההרשאות"
fires even when the user accepted the trust dialog on install and the
publisher cert is unchanged: ApplicationDeployment.CheckForDetailedUpdate
re-runs the TrustManager evaluation, which can't show a UAC-style
prompt from inside a hosted Outlook process and falls through to a
denial exception.

Bypass the ClickOnce update API entirely:
1. Read CurrentVersion from ApplicationDeployment (this part is fine)
2. HTTP GET the .vsto manifest at UpdateLocation, parse the top-level
   <assemblyIdentity> version
3. Compare versions
4. If newer, Process.Start the .vsto URL with UseShellExecute=true so
   Windows hands it to VSTOInstaller.exe — which is the only thing that
   actually knows how to update a running VSTO add-in correctly

ApplicationDeployment.Update() is also dropped because it has the same
in-process limitation as the trust check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:57:02 +03:00
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
36 changed files with 1538 additions and 359 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
+13 -7
View File
@@ -258,15 +258,21 @@ jobs:
& msbuild @msbuildArgs
if ($LASTEXITCODE -ne 0) { throw "msbuild /t:Publish failed with $LASTEXITCODE" }
# Sanity check: did the VSTO publish target produce a .vsto file?
$vsto = Get-ChildItem -Recurse -Filter '*.vsto' -Path publish | Select-Object -First 1
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found under publish/" }
# Sanity check + rename to canonical OutlookAddin.vsto so the URL
# matches PROVIDER_URL (hardcoded to .../OutlookAddin.vsto). Restrict
# to publish/ root -- there's a second .vsto inside Application Files
# that must keep its versioned name. Verify the renamed file exists
# before continuing; v1.2.11 shipped broken because a silent failure
# here left no OutlookAddin.vsto in the tar and 404'd the CDN.
$vsto = Get-ChildItem -File -Filter '*.vsto' -Path publish | Select-Object -First 1
if (-not $vsto) { throw "msbuild /t:Publish completed but no .vsto file found at publish/ root" }
Write-Host " produced $($vsto.FullName)"
# Rename to canonical OutlookAddin.vsto so the ClickOnce manifest
# URL matches PROVIDER_URL (which is hardcoded to .../OutlookAddin.vsto).
if ($vsto.Name -ne 'OutlookAddin.vsto') {
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto'
Rename-Item -LiteralPath $vsto.FullName -NewName 'OutlookAddin.vsto' -ErrorAction Stop
Write-Host " renamed -> OutlookAddin.vsto"
}
if (-not (Test-Path 'publish/OutlookAddin.vsto')) {
throw "post-rename: publish/OutlookAddin.vsto does not exist"
}
Write-Host "=== STEP F: stage protocol handler ==="
+8 -2
View File
@@ -24,16 +24,22 @@ project.lock.json
project.fragment.lock.json
# 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/
*.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
*.vsto
*.application
*.deploy
*.manifest
!Publish/**
# Code signing — NEVER commit private keys
*.pfx
+5 -10
View File
@@ -315,16 +315,11 @@ git push origin v1.0.1
ה-CI ירוץ אוטומטית כמו בשחרור הראשון.
**אצל הלקוחות:**
- ClickOnce בודק עדכון אוטומטית **כל 7 ימים** (הגדרה ב-csproj: `UpdateInterval=7`).
- כדי **להאיץ** עדכון אצל לקוח מסויים:
1. סגור את Outlook לגמרי.
2. פתח את Outlook שוב.
3. אם עברו לפחות 7 ימים מבדיקת ה-update הקודמת — יבדוק עכשיו ויעדכן.
4. אם לא — אפשר לנקות את ה-ClickOnce cache:
```powershell
rundll32.exe dfshim.dll,CleanOnlineAppCache
```
ואז לפתוח שוב את `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`.
- ה־VSTO runtime בודק את ה-`.vsto` manifest **בכל פתיחה של Outlook** (משוחזר מ-`<UpdatePeriodically>false</UpdatePeriodically>` ב-csproj החל מ-v1.2.9; קודם זה היה פעם ב-7 ימים). הבדיקה היא GET של ~6KB, ~100ms — בלתי מורגש בזמן הטעינה.
- ה-CI מציב `/p:MinimumRequiredVersion=<tag>` בכל build, אז מיד כשהבדיקה מזהה גרסה חדשה — ההתקנה נכפית בלי דיאלוג.
- **תרגום מעשי**: 30 שניות אחרי `git push --tags` ה-CI מסיים → תוך 2-3 דקות החבילה נדחפת ל-CDN → בכניסה הבאה של כל אחד מהלקוחות ל-Outlook הוא רץ על הגרסה החדשה. **בלי שום פעולה ידנית מצידם**.
- **הכפתור "בדוק עדכונים" בהגדרות** רק מודיע ("זמינה גרסה X — סגור ופתח Outlook"). הוא לא יכול להתקין מתוך תהליך Outlook רץ — `VSTOInstaller.exe` לא יכול לדרוס DLLs נעולים, וה-ApplicationDeployment API נכשל ב-`TrustNotGrantedException` בהקשר VSTO. ההתקנה תמיד קורית רק דרך ה-runtime בעלייה של Outlook. [פירוט בזיכרון: feedback_vsto_updater_api.md]
- **תקלה נדירה — לקוח שהותקן ידנית עם dev cert** (`HP-OFFICE1\<user>`, publicKeyToken `41d8d795775ea8cb`) לא יכול לעבור auto-update לגרסת prod cert (`Marcus-Law OutlookAddin`, publicKeyToken `c68d2b4c25051c5b`) — ClickOnce מתייחס אליהן כשתי אפליקציות שונות. **פעם אחת**: Apps & Features → uninstall של MarcusLaw.OutlookAddin → התקנה חדשה מ-`https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`. אחרי זה auto-update עובד.
---
+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 **on every Outlook startup** (csproj has `<UpdatePeriodically>false</UpdatePeriodically>` since v1.2.9 — earlier versions waited up to 7 days). The check is a single ~6 KB HTTP GET, ~100 ms. Combined with the CI setting `/p:MinimumRequiredVersion=<tag>` per build, this means every tagged release lands on every lawyer's PC the next time they open Outlook, silently and without prompts.
**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
| 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 |
| 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 |
| 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 |
---
@@ -23,6 +23,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
[JsonPropertyName("status")]
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")]
public string? AccountId { get; set; }
@@ -17,6 +17,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
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;
}
}
@@ -46,35 +46,17 @@ namespace MarcusLaw.OutlookAddin.Core.Services
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
{
// Step 1: create the EspoCRM Attachment (binary stored
// in data/upload/<id>; not yet on the network share).
var uploaded = await _client.UploadAttachmentAsync(
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
// 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
});
attachmentId = uploaded.Id;
}
catch (EspoCrmAuthorizationException ex)
{
@@ -86,20 +68,85 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Outcome = AttachmentSaveOutcome.AuthRequired,
ErrorMessage = ex.Message
});
continue;
}
catch (OperationCanceledException)
{
throw;
}
catch (OperationCanceledException) { throw; }
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
{
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
});
}
}
@@ -107,51 +154,60 @@ namespace MarcusLaw.OutlookAddin.Core.Services
}
/// <summary>
/// Post-upload sanity check: list the folder we just wrote into
/// and confirm a file with the same (possibly sanitized) name
/// shows up. Diagnostic only — does not change the outcome. If
/// the file isn't there, log a WARNING with everything that IS
/// in the folder so we can spot the actual server-side path.
/// 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 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
{
var items = await _client.ListNetworkStorageFolderAsync(folderPath, ct).ConfigureAwait(false);
var trimmedExpected = expectedName.Trim().TrimStart('-', ' ').TrimEnd('-', ' ');
bool found = false;
foreach (var it in items)
{
if (it.IsFolder) continue;
if (string.Equals(it.Name, expectedName, StringComparison.Ordinal) ||
string.Equals(it.Name, trimmedExpected, StringComparison.Ordinal))
{
found = true;
break;
return true;
}
}
if (!found)
{
var names = new List<string>();
foreach (var it in items)
{
if (it.IsFolder) continue;
names.Add(it.Name);
}
_logger.Warning(
"Post-upload verify: expected '{Expected}' under '{Path}' but it's NOT in the listing. " +
"Server reported the upload as successful but the file is not on the share. " +
"Files currently in that folder: [{Files}]",
expectedName, folderPath, string.Join(", ", names));
}
else
{
_logger.Information("Post-upload verify: '{Expected}' present under '{Path}'", expectedName, folderPath);
}
return false;
}
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;
}
}
}
@@ -213,13 +213,29 @@ namespace MarcusLaw.OutlookAddin.Core.Services
// 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.
//
// We bind the upload to Note.attachments (parentType=Note,
// field=attachments). EspoCRM's attachment access checker now
// REJECTS a role=Attachment upload that carries neither `field`
// nor `parentType` ("No `field` and `parentType`." → 400) — the
// earlier "unbound blob" shape no longer passes. Note.attachments
// is the documented Attachment-Multiple example and a built-in
// field that carries NO "allowed file types" restriction, so every
// file type (PDF, JPEG, PNG, …) passes — unlike Document.file,
// which Marcus-Law limits to PDF/Word and which rejected images
// with 403 "Not allowed file type".
//
// We never set parentId / create a Note: the attachment stays a
// standalone blob whose id we immediately push to the network
// share. The (parentType, field) pair only satisfies the access
// checker and selects an unrestricted file-type policy.
var payload = new Dictionary<string, object?>
{
["name"] = fileName,
["type"] = mime,
["role"] = "Attachment",
["relatedType"] = "Document",
["field"] = "file",
["parentType"] = "Note",
["field"] = "attachments",
["file"] = dataUri
};
@@ -13,6 +13,15 @@ namespace MarcusLaw.OutlookAddin.Core.Services
/// </summary>
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);
}
}
+116 -44
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
@@ -44,60 +45,51 @@ namespace MarcusLaw.OutlookAddin.Core.Services
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
{
SenderEmail = senderEmail,
IsAmbiguous = true,
CandidateCount = matches.Count
};
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous;
_cache.Set(key, (MatchResult?)null, CacheExpiry);
return null;
}
var contactRef = matches[0];
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
AccountEntity? account = null;
if (!string.IsNullOrWhiteSpace(contact.AccountId))
if (distinctIds.Count == 1)
{
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 {Email}", contact.AccountId, senderEmail);
}
var single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false);
_cache.Set<MatchResult?>(key, single, CacheExpiry);
return single;
}
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);
}
// Two or more genuinely distinct contacts. Fetch each one's
// full record (in parallel) so the picker can show real
// name + account, not just the bare email payload.
var fetched = await Task.WhenAll(
distinctIds.Select(id => SafeGetContactAsync(id, cancellationToken))
).ConfigureAwait(false);
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,
Contact = contact,
Account = account,
RecentCases = cases,
CandidateCount = 1
IsAmbiguous = true,
CandidateCount = candidates.Count,
Candidates = candidates
};
_cache.Set<MatchResult?>(key, result, CacheExpiry);
return result;
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous;
}
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)
{
if (string.IsNullOrWhiteSpace(senderEmail)) return;
@@ -84,6 +84,10 @@ namespace MarcusLaw.OutlookAddin.UI.Converters
["New"] = "חדש",
["Assigned"] = "הוקצה",
["Pending"] = "ממתין",
["PendingHearing"] = "ממתין לדיון",
["Pending Hearing"] = "ממתין לדיון",
["PendingDecision"] = "ממתין להחלטה",
["Pending Decision"] = "ממתין להחלטה",
["In Progress"] = "בטיפול",
["InProgress"] = "בטיפול",
["Open"] = "פתוח",
@@ -1,9 +1,10 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
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="כתוב מייל מתיק"
Height="480" Width="600"
MinHeight="320" MinWidth="500"
Height="520" Width="680"
MinHeight="360" MinWidth="540"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
@@ -11,6 +12,7 @@
Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
@@ -134,24 +136,30 @@
</Border>
<Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
BorderThickness="0"
Background="Transparent"
ItemContainerStyle="{StaticResource ResultItem}"
Padding="4">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock FontSize="13">
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{Binding Status}" Foreground="{StaticResource Muted}" FontSize="11" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#E2E8F0"
RowBackground="White"
AlternatingRowBackground="#F8FAFC"
BorderThickness="0"
CanUserAddRows="False"
CanUserDeleteRows="False"
CanUserResizeRows="False"
CanUserReorderColumns="False"
SelectionMode="Single"
SelectionUnit="FullRow"
IsReadOnly="True"
RowHeight="30">
<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>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
@@ -1,9 +1,10 @@
<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"
Height="640" Width="620"
MinHeight="480" MinWidth="500"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
@@ -11,6 +12,9 @@
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" />
@@ -80,32 +84,28 @@
</Setter>
</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>
<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>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -130,32 +130,52 @@
<!-- Cases -->
<Border Grid.Row="1" Style="{StaticResource Card}">
<ListBox ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
BorderThickness="0"
Background="Transparent"
ItemContainerStyle="{StaticResource CaseItem}"
Padding="4">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock FontSize="13">
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<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" />
<ComboBox ItemsSource="{Binding Subfolders}"
SelectedItem="{Binding SelectedSubfolder}"
Padding="6,4" />
<DockPanel DockPanel.Dock="Top" LastChildFill="True" Margin="0,0,0,4">
<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}}" />
<TextBlock Text="בחר/י תיקייה או תת-תיקייה לשמירה (ניתן להרחיב רמות)"
VerticalAlignment="Center" Foreground="{StaticResource Muted}" FontSize="11"
TextTrimming="CharacterEllipsis" />
</DockPanel>
<Border Style="{StaticResource Card}" MaxHeight="150">
<TreeView x:Name="FolderTreeView"
ItemsSource="{Binding FolderTree}"
BorderThickness="0"
Background="Transparent"
SelectedItemChanged="FolderTreeView_SelectedItemChanged">
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" Margin="0,1">
<TextBlock Text="📁" Margin="0,0,4,0" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="{Binding Name}" VerticalAlignment="Center" FontSize="13" />
</StackPanel>
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</Border>
</DockPanel>
<!-- Attachments header -->
@@ -178,10 +198,20 @@
FontSize="11"
VerticalAlignment="Center"
Margin="8,0,0,0" />
<TextBlock Text="{Binding FileName}"
<TextBlock Text="{Binding Extension}"
DockPanel.Dock="Left"
Foreground="{StaticResource Muted}"
FontSize="12"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Margin="8,0,0,0" />
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>
@@ -32,5 +32,15 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs
DialogResult = _viewModel?.DialogResult;
Close();
}
// WPF's TreeView.SelectedItem is read-only, so we mirror the selection
// into the view-model here (the chosen node drives the save target).
private void FolderTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
{
if (_viewModel != null)
{
_viewModel.SelectedFolder = e.NewValue as FolderNodeViewModel;
}
}
}
}
@@ -98,8 +98,19 @@
<TabItem Header="אודות">
<StackPanel Margin="16">
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
<TextBlock Text="תוסף Klear ל-Microsoft Outlook" Margin="0,4,0,12" />
<Border Background="#121d2e" CornerRadius="6" Padding="14,12" Margin="0,0,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">
<TextBlock DockPanel.Dock="Left" Text="{Binding VersionLine}" Foreground="Gray" VerticalAlignment="Center" />
<Button DockPanel.Dock="Right"
@@ -112,10 +123,22 @@
Foreground="{Binding UpdateStatusBrush}"
TextWrapping="Wrap"
Margin="0,4,0,0" />
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap"
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
<TextBlock TextWrapping="Wrap"
Text="הגדרות: %APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
<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>
</TabItem>
</TabControl>
@@ -1,5 +1,8 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Documents;
using MarcusLaw.OutlookAddin.UI.ViewModels;
namespace MarcusLaw.OutlookAddin.UI.Dialogs
@@ -40,5 +43,23 @@ namespace MarcusLaw.OutlookAddin.UI.Dialogs
DialogResult = _viewModel?.DialogResult;
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" />
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\logo-klear.png" />
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -1,3 +1,4 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Models;
@@ -10,6 +11,34 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
[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 &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 SizeDisplay { get; }
@@ -17,6 +46,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public AttachmentRowViewModel(EspoAttachment data)
{
Data = data;
Extension = Path.GetExtension(data.Name) ?? string.Empty;
SizeDisplay = ComputeSize(data.ContentBase64);
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
@@ -74,20 +75,36 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested();
Cases.Clear();
int kept = 0;
var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List)
{
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
Cases.Add(new CaseEntity
{
Id = hit.Id,
Name = hit.Name,
});
kept++;
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
caseHits.Add(hit);
}
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)"
: $"נמצאו {kept} תיקים";
if (caseHits.Count == 0)
{
StatusMessage = "אין תוצאות מסוג תיק (Case)";
return;
}
// GlobalSearch only returns id+name. Hydrate each hit with
// number+status (and the Hebrew case name when present) so the
// picker shows the same columns the file-attachments picker
// does.
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)
@@ -101,6 +118,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
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, "ComposeFromCase: hydrating case {CaseId} failed", hit.Id);
}
return row;
}
[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit()
{
@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
/// <summary>
/// One node in the network-storage folder tree shown by the
/// save-attachments dialog. Children are listed lazily the first time the
/// node is expanded, so the user can drill into nested subfolders to any
/// depth without us listing the whole tree up front.
/// </summary>
public sealed partial class FolderNodeViewModel : ObservableObject
{
private readonly IEspoCrmClient? _client;
private readonly ILogger? _logger;
private readonly Dispatcher? _dispatcher;
private bool _childrenLoaded;
private bool _loadingStarted;
/// <summary>Label shown in the tree.</summary>
public string Name { get; }
/// <summary>
/// Path relative to the share root (e.g. "56-ליאור זייני/מסמכים").
/// Used to list this folder's children. Empty for placeholders.
/// </summary>
public string FullPath { get; }
/// <summary>
/// Path relative to the case-root folder (e.g. "מסמכים/רפואי"), or
/// null for the case-root node itself. This is what the save target
/// appends to the resolved case folder.
/// </summary>
public string? RelativePath { get; }
/// <summary>True for the transient "loading…" child placeholder.</summary>
public bool IsPlaceholder { get; }
public bool IsRoot => !IsPlaceholder && RelativePath == null;
public ObservableCollection<FolderNodeViewModel> Children { get; } =
new ObservableCollection<FolderNodeViewModel>();
[ObservableProperty]
private bool isExpanded;
[ObservableProperty]
private bool isSelected;
[ObservableProperty]
private bool isLoading;
/// <summary>Real folder node.</summary>
public FolderNodeViewModel(
IEspoCrmClient client,
ILogger logger,
Dispatcher dispatcher,
string name,
string fullPath,
string? relativePath)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
Name = name;
FullPath = fullPath;
RelativePath = relativePath;
// A placeholder child makes the expand chevron appear before we've
// listed; expanding swaps it for the real children (or, if there
// are none, the node becomes a leaf).
Children.Add(new FolderNodeViewModel());
}
/// <summary>Placeholder ("loading…") node constructor.</summary>
private FolderNodeViewModel()
{
Name = "טוען…";
FullPath = string.Empty;
RelativePath = string.Empty;
IsPlaceholder = true;
_childrenLoaded = true;
}
partial void OnIsExpandedChanged(bool value)
{
if (value && !_childrenLoaded && !_loadingStarted)
{
_loadingStarted = true;
_ = LoadChildrenAsync();
}
}
/// <summary>
/// Lists this folder's subfolders and replaces the placeholder with
/// real child nodes. <paramref name="seedNames"/> (used for the root)
/// guarantees the default case subfolders show even if the share
/// listing lags or omits a not-yet-created folder.
/// </summary>
public async Task LoadChildrenAsync(IEnumerable<string>? seedNames = null)
{
if (_client == null) return;
IsLoading = true;
// Build into a local list and swap once at the end. The seeds are
// added first so that even if the share listing fails the default
// subfolders still show; on failure for a non-seeded node the list
// is empty and the node reads as a leaf rather than "loading…".
var seen = new HashSet<string>(StringComparer.Ordinal);
var loaded = new List<FolderNodeViewModel>();
if (seedNames != null)
{
foreach (var n in seedNames)
{
if (seen.Add(n)) loaded.Add(MakeChild(n));
}
}
try
{
var items = await _client.ListNetworkStorageFolderAsync(FullPath).ConfigureAwait(true);
foreach (var item in items)
{
if (!item.IsFolder) continue;
if (!seen.Add(item.Name)) continue;
loaded.Add(MakeChild(item.Name));
}
}
catch (Exception ex)
{
_logger?.Warning(ex, "Listing subfolders under '{Path}' failed", FullPath);
}
finally
{
// Marshal onto the Dispatcher: once this node's TreeViewItem
// exists, Children is bound and must only be mutated on the UI
// thread (the network await above can resume on a thread-pool
// thread in this VSTO host).
OnUi(() =>
{
Children.Clear();
foreach (var child in loaded) Children.Add(child);
});
_childrenLoaded = true;
IsLoading = false;
}
}
private FolderNodeViewModel MakeChild(string name)
{
var fullPath = string.IsNullOrEmpty(FullPath) ? name : FullPath + "/" + name;
var relativePath = IsRoot ? name : RelativePath + "/" + name;
return new FolderNodeViewModel(_client!, _logger!, _dispatcher!, name, fullPath, relativePath);
}
private void OnUi(Action action)
{
var d = _dispatcher;
if (d == null || d.CheckAccess()) action();
else d.Invoke(action);
}
}
}
@@ -30,6 +30,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IMatchingService _matchingService;
private readonly ILogger _logger;
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]
private ReadingPaneState state = ReadingPaneState.Empty;
@@ -51,6 +54,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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 LoadingVisibility => Vis(State == ReadingPaneState.Loading);
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 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>
/// Raised when the user clicks "File to this case" (carries the chosen
/// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null).
@@ -89,6 +110,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
OnPropertyChanged(nameof(ErrorVisibility));
OnPropertyChanged(nameof(ActionsVisibility));
OnPropertyChanged(nameof(NoCasesVisibility));
OnPropertyChanged(nameof(MultiCaseHintVisibility));
OnPropertyChanged(nameof(SingleCaseFileVisibility));
OnPropertyChanged(nameof(BackToCandidatesVisibility));
FileToThisCommand.NotifyCanExecuteChanged();
FileElsewhereCommand.NotifyCanExecuteChanged();
OpenInEspoCrmCommand.NotifyCanExecuteChanged();
@@ -101,8 +125,16 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
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(NoCasesVisibility));
OnPropertyChanged(nameof(MultiCaseHintVisibility));
OnPropertyChanged(nameof(SingleCaseFileVisibility));
OnPropertyChanged(nameof(BackToCandidatesVisibility));
}
public void Reset()
@@ -112,6 +144,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
Match = null;
ErrorMessage = null;
AmbiguousMessage = null;
_ambiguousReturnTarget = null;
State = ReadingPaneState.Empty;
}
@@ -125,6 +158,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
SenderEmail = senderEmail;
// New email selection — drop any stashed candidate-list state.
_ambiguousReturnTarget = null;
State = ReadingPaneState.Loading;
var cts = new CancellationTokenSource();
@@ -182,7 +217,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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))]
private void FileElsewhere()
@@ -202,12 +258,88 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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]
private void OpenManualSearch()
{
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;
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models;
@@ -31,6 +32,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
private readonly Dispatcher _dispatcher;
private CancellationTokenSource? _searchCts;
[ObservableProperty]
@@ -40,11 +42,14 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private CaseEntity? selectedCase;
[ObservableProperty]
private string? selectedSubfolder;
private FolderNodeViewModel? selectedFolder;
[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
@@ -55,7 +60,12 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
/// <summary>
/// Folder tree for the selected case. Holds a single root node (the
/// case folder); its children are the subfolders, loaded lazily so the
/// user can drill into any nesting depth.
/// </summary>
public ObservableCollection<FolderNodeViewModel> FolderTree { get; } = new ObservableCollection<FolderNodeViewModel>();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
@@ -75,14 +85,20 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
/// <summary>
/// Subfolder string the user picked, or null to save to the case
/// folder root.
/// Folder the user picked, as a path relative to the case folder
/// (e.g. "מסמכים/רפואי"), or null to save to the case folder root.
/// Multi-segment paths are honoured by the save target, which appends
/// this to <see cref="ResolvedCaseFolderPath"/>.
/// </summary>
public string? ChosenSubfolderName =>
string.IsNullOrEmpty(SelectedSubfolder) ||
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
? null
: SelectedSubfolder;
public string? ChosenSubfolderName
{
get
{
var f = SelectedFolder;
if (f == null || f.IsPlaceholder || f.IsRoot) return null;
return string.IsNullOrEmpty(f.RelativePath) ? null : f.RelativePath;
}
}
public CaseEntity? PickedCase => SelectedCase;
@@ -95,8 +111,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Captured on the UI thread (the VM is constructed on the thread
// that shows the dialog). Used to marshal ObservableCollection
// mutations back onto the Dispatcher — in this VSTO host the WPF
// SynchronizationContext is not installed, so ConfigureAwait(true)
// resumes on a thread-pool thread and a bound-collection Add throws
// "changes ... from a thread different from the Dispatcher thread".
_dispatcher = Dispatcher.CurrentDispatcher;
ResetSubfolders();
ResetFolderTree();
foreach (var att in attachments)
{
@@ -104,12 +127,52 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
private void ResetSubfolders()
/// <summary>
/// Seeds the case list with the cases already resolved for the mail's
/// sender (from the sidebar match) so the user doesn't have to search
/// for a client we've already identified. When exactly one case is
/// known we auto-select it (which kicks the subfolder/folder-path
/// resolve); with several we just show them and let the user pick.
/// The user can still type in the search box to override — that
/// repopulates <see cref="Cases"/> from a fresh server search.
/// </summary>
public void PreselectCases(IReadOnlyList<CaseEntity> cases)
{
Subfolders.Clear();
Subfolders.Add(RootSentinel);
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
SelectedSubfolder = Subfolders[0];
if (cases == null || cases.Count == 0) return;
Cases.Clear();
foreach (var c in cases)
{
if (c == null || string.IsNullOrEmpty(c.Id)) continue;
Cases.Add(c);
}
if (Cases.Count == 0) return;
if (Cases.Count == 1)
{
SelectedCase = Cases[0];
StatusMessage = "התיק זוהה אוטומטית מהשולח";
}
else
{
StatusMessage = $"{Cases.Count} תיקים מקושרים לשולח — בחר/י תיק";
}
}
/// <summary>Runs <paramref name="action"/> on the UI/Dispatcher thread.</summary>
private void OnUi(Action action)
{
if (_dispatcher.CheckAccess()) action();
else _dispatcher.Invoke(action);
}
private void ResetFolderTree()
{
OnUi(() =>
{
FolderTree.Clear();
SelectedFolder = null;
});
}
partial void OnSearchTextChanged(string value)
@@ -134,12 +197,25 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync()
{
ResetSubfolders();
ResetFolderTree();
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"); }
@@ -172,21 +248,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
if (string.IsNullOrEmpty(folderPath)) return;
// Build the tree rooted at the case folder. The root node is
// selected by default (→ save to case root, matching the previous
// default), expanded, and seeded with the default subfolders so
// they appear even before the share listing returns. Deeper levels
// load lazily as the user expands them.
var root = new FolderNodeViewModel(_client, _logger, _dispatcher, RootSentinel, folderPath, relativePath: null);
OnUi(() =>
{
FolderTree.Add(root);
SelectedFolder = root;
root.IsSelected = true;
});
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);
}
await root.LoadChildrenAsync(DefaultCaseSubfolders).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
}
OnUi(() => root.IsExpanded = true);
}
/// <summary>
@@ -237,6 +320,27 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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
@@ -255,20 +359,32 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
ct.ThrowIfCancellationRequested();
Cases.Clear();
int kept = 0;
var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List)
{
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
Cases.Add(new CaseEntity
{
Id = hit.Id,
Name = hit.Name
});
kept++;
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
caseHits.Add(hit);
}
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)"
: $"נמצאו {kept} תיקים";
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)
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Media;
@@ -91,12 +92,13 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
/// </summary>
private static string ResolveDisplayVersion()
{
Version? v = null;
try
{
if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{
return System.Deployment.Application.ApplicationDeployment
.CurrentDeployment.CurrentVersion.ToString();
v = System.Deployment.Application.ApplicationDeployment
.CurrentDeployment.CurrentVersion;
}
}
catch
@@ -104,7 +106,30 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
// Not running under ClickOnce (dev / F5), or System.Deployment
// is not available — fall through to the AssemblyVersion.
}
return typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
// 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>
@@ -154,18 +179,61 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
var deployment = System.Deployment.Application.ApplicationDeployment.CurrentDeployment;
var info = await Task.Run(() => deployment.CheckForDetailedUpdate(false)).ConfigureAwait(true);
if (!info.UpdateAvailable)
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
{
UpdateStatusBrush = SuccessBrush;
UpdateStatus = "אתה על הגרסה האחרונה (" + deployment.CurrentVersion + ").";
availableVersion = await Task.Run(() => FetchManifestVersion(manifestUri)).ConfigureAwait(true);
}
catch (Exception fetchEx)
{
_logger.Warning(fetchEx, "CheckForUpdate: manifest fetch failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "שגיאה בבדיקת השרת: " + fetchEx.Message;
return;
}
UpdateStatus = $"זמינה גרסה {info.AvailableVersion} — מוריד…";
var result = await Task.Run(() => deployment.Update()).ConfigureAwait(true);
if (availableVersion == null)
{
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "לא ניתן לקרוא את גרסת השרת מ-" + manifestUri;
return;
}
if (availableVersion <= currentVersion)
{
UpdateStatusBrush = SuccessBrush;
UpdateStatus = "אתה על הגרסה האחרונה (" + currentVersion + ").";
return;
}
// A running Outlook holds file locks on the loaded add-in
// DLLs. Neither ApplicationDeployment.Update (TrustNotGranted)
// nor a direct VSTOInstaller.exe /I call (0x8007007E
// ERROR_MOD_NOT_FOUND) nor Process.Start(.vsto URL) (Chrome
// downloads to local-machine zone — InvalidDeploymentException)
// can install over those locks. The only path that actually
// works is the one VSTO runtime takes automatically on Outlook
// startup: it checks the manifest URL before loading the
// add-in, downloads + installs if there's a newer version,
// then loads it.
//
// So the button's job is just to tell the user: "close and
// reopen Outlook." That's the same flow the user has been
// doing manually all along; we're just confirming the new
// version is there waiting.
UpdateStatusBrush = SuccessBrush;
UpdateStatus = $"הותקנה גרסה {info.AvailableVersion}. סגור ופתח את Outlook כדי לטעון אותה.";
UpdateStatus =
$"זמינה גרסה {availableVersion}. סגור ופתח את Outlook — " +
"הגרסה החדשה תותקן אוטומטית בעלייה.";
return;
}
catch (System.Deployment.Application.DeploymentDownloadException ex)
{
@@ -191,6 +259,33 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
}
/// <summary>
/// Reads the top-level &lt;assemblyIdentity&gt; 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)
+69 -10
View File
@@ -62,6 +62,29 @@
<!-- Ambiguous -->
<StackPanel Visibility="{Binding AmbiguousVisibility}">
<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="פתח חיפוש ידני"
Command="{Binding OpenManualSearchCommand}"
Style="{StaticResource LinkButtonStyle}"
@@ -70,8 +93,20 @@
<!-- Match -->
<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 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 Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" />
@@ -82,14 +117,33 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
<StackPanel>
<TextBlock>
<Run Text="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}"
Foreground="Gray" FontSize="10" />
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock>
<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>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -110,7 +164,12 @@
<Button Content="תייק לתיק הזה"
Command="{Binding FileToThisCommand}"
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="תייק למקום אחר…"
Command="{Binding FileElsewhereCommand}"
Padding="8,4" Margin="0,0,0,4"
+8 -6
View File
@@ -31,16 +31,15 @@
<Nullable>enable</Nullable>
<DefineConstants>VSTO40;UseOfficeInterop</DefineConstants>
<ResolveComReferenceSilent>true</ResolveComReferenceSilent>
<IsWebBootstrapper>False</IsWebBootstrapper>
<IsWebBootstrapper>True</IsWebBootstrapper>
<BootstrapperEnabled>true</BootstrapperEnabled>
<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>
<ApplicationVersion>1.0.0.5</ApplicationVersion>
<AutoIncrementApplicationRevision>true</AutoIncrementApplicationRevision>
<ApplicationVersion>1.2.10.0</ApplicationVersion>
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
<UpdateEnabled>true</UpdateEnabled>
<UpdateInterval>7</UpdateInterval>
<UpdateIntervalUnits>days</UpdateIntervalUnits>
<UpdatePeriodically>false</UpdatePeriodically>
<ProductName>MarcusLaw.OutlookAddin</ProductName>
<PublisherName />
<SupportUrl />
@@ -258,6 +257,9 @@
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Ribbon\ReadMailRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.ReadMailRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Images\klear-file.png">
<LogicalName>OutlookAddin.Images.klear-file.png</LogicalName>
</EmbeddedResource>
+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
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyVersion("1.2.16.0")]
[assembly: AssemblyFileVersion("1.2.16.0")]
+49
View File
@@ -19,6 +19,7 @@ namespace OutlookAddin.Ribbon
{
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
private const string ReadMailResource = "OutlookAddin.Ribbon.ReadMailRibbon.xml";
// Every button whose `getEnabled` depends on the current Explorer
// selection must be re-evaluated on every SelectionChange — Office
@@ -40,6 +41,8 @@ namespace OutlookAddin.Ribbon
return LoadResource(ExplorerResource);
case "Microsoft.Outlook.Mail.Compose":
return LoadResource(InspectorResource);
case "Microsoft.Outlook.Mail.Read":
return LoadResource(ReadMailResource);
default:
return string.Empty;
}
@@ -77,6 +80,51 @@ namespace OutlookAddin.Ribbon
_inspectorRibbon = ribbon;
}
// The read-mail inspector ribbon loads fresh for each opened message,
// so its getEnabled callback re-runs per window — no cached IRibbonUI
// or invalidation is needed here.
public void OnReadMailRibbonLoad(IRibbonUI ribbon)
{
}
/// <summary>
/// Enabled when the message open in this read inspector has at least
/// one attachment. Mirrors <see cref="OnGetSaveAttachmentsEnabled"/>
/// but reads the item from the Inspector context rather than the
/// Explorer selection.
/// </summary>
public bool OnGetSaveAttachmentsInspectorEnabled(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem 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 OnSaveAttachmentsFromInspector(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem is Outlook.MailItem mail)
{
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
}
}
catch (Exception ex)
{
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachmentsFromInspector failed");
}
}
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
{
try
@@ -206,6 +254,7 @@ namespace OutlookAddin.Ribbon
{
case "FileToEspoCrm": fileName = "klear-file.png"; break;
case "SaveAttachments": fileName = "klear-attach.png"; break;
case "SaveAttachmentsRead": fileName = "klear-attach.png"; break;
case "ShowSidebar": fileName = "klear-sidebar.png"; break;
case "OpenEspoCrmSettings": fileName = "klear-settings.png"; break;
case "ComposeFromCase": fileName = "klear-compose.png"; break;
+1 -1
View File
@@ -3,7 +3,7 @@
<ribbon>
<tabs>
<tab idMso="TabMail">
<group id="MarcusLawGroup" label="Marcus-Law" insertBeforeMso="GroupMailMove">
<group id="MarcusLawGroup" label="Klear" insertBeforeMso="GroupMailMove">
<button id="FileToEspoCrm"
label="Klear"
screentip="תייק את המייל הנבחר ל-Klear"
+1 -1
View File
@@ -3,7 +3,7 @@
<ribbon>
<tabs>
<tab idMso="TabNewMailMessage">
<group id="MarcusLawComposeGroup" label="Marcus-Law" insertBeforeMso="GroupClipboard">
<group id="MarcusLawComposeGroup" label="Klear" insertBeforeMso="GroupClipboard">
<button id="ComposeFromCase"
label="כתוב מתיק"
screentip="פתח את המייל מתיק Klear"
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="OnReadMailRibbonLoad">
<ribbon>
<tabs>
<tab idMso="TabReadMessage">
<group id="MarcusLawReadGroup" label="Klear">
<button id="SaveAttachmentsRead"
label="שמור קבצים"
screentip="שמור את הקבצים המצורפים בלבד"
supertip="מעלה את הקבצים המצורפים של המייל הזה ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
size="large"
getImage="OnGetImage"
onAction="OnSaveAttachmentsFromInspector"
getEnabled="OnGetSaveAttachmentsInspectorEnabled" />
</group>
</tab>
</tabs>
</ribbon>
</customUI>
+164 -56
View File
@@ -127,6 +127,62 @@ namespace OutlookAddin.Services
}
}
/// <summary>
/// Shows a message box that is guaranteed to render above Outlook and
/// any other window. A bare <see cref="MessageBox.Show(string)"/> has
/// no owner — once our WPF dialog closes there is no active window on
/// the add-in thread, so the box sinks behind Outlook and the user
/// never sees the success/error result. We anchor it to a transient,
/// invisible top-most owner window parented to the foreground Outlook
/// window so it always surfaces on top. Caption is always "Klear".
/// </summary>
private MessageBoxResult ShowMessage(
string text,
MessageBoxButton button = MessageBoxButton.OK,
MessageBoxImage icon = MessageBoxImage.None)
{
Window? owner = null;
try
{
// A 1×1 borderless window at screen centre: imperceptible, but
// gives the MessageBox a top-most owner to z-order above. The
// box centres on this point → effectively centred on screen.
owner = new Window
{
Width = 1,
Height = 1,
WindowStyle = WindowStyle.None,
ResizeMode = ResizeMode.NoResize,
ShowInTaskbar = false,
Topmost = true,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
};
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero)
{
hwnd = System.Diagnostics.Process.GetCurrentProcess().MainWindowHandle;
}
if (hwnd != IntPtr.Zero)
{
new WindowInteropHelper(owner).Owner = hwnd;
}
owner.Show();
owner.Activate();
return MessageBox.Show(owner, text, "Klear", button, icon);
}
catch (Exception ex)
{
Logger.Warning(ex, "ShowMessage top-most owner failed; falling back to ownerless box");
return MessageBox.Show(text, "Klear", button, icon);
}
finally
{
try { owner?.Close(); } catch { /* ignore */ }
}
}
private static void EnableModernTls()
{
try
@@ -402,9 +458,8 @@ namespace OutlookAddin.Services
if (mail == null) return;
if (!IsConfigured)
{
MessageBox.Show(
ShowMessage(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -423,9 +478,8 @@ namespace OutlookAddin.Services
catch (Exception ex)
{
Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
MessageBox.Show(
ShowMessage(
"טעינת התיק נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -444,9 +498,8 @@ namespace OutlookAddin.Services
if (mail == null) return;
if (!IsConfigured)
{
var result = MessageBox.Show(
var result = ShowMessage(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Marcus-Law OutlookAddin",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes) LaunchSettings();
@@ -463,9 +516,8 @@ namespace OutlookAddin.Services
catch (Exception ex)
{
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
MessageBox.Show(
ShowMessage(
"קריאת הקבצים המצורפים נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Error);
return;
@@ -473,9 +525,8 @@ namespace OutlookAddin.Services
if (envelope.Attachments.Count == 0)
{
MessageBox.Show(
ShowMessage(
"אין במייל הזה קבצים מצורפים לשמירה.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -485,6 +536,13 @@ namespace OutlookAddin.Services
// Subfolders are populated lazily once the user picks a case;
// the default static set is already pre-loaded.
// If the sender already maps to a known client, seed the case
// list so the user sees the identified case pre-selected instead
// of having to search. Uses the same matching lookup the sidebar
// does (cached for 10 min), so this is normally instant. Any
// failure here is non-fatal — the dialog still opens empty.
await TryPreselectSenderCaseAsync(vm, envelope.From).ConfigureAwait(true);
var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog);
var ok = dialog.ShowDialog();
@@ -503,9 +561,8 @@ namespace OutlookAddin.Services
: SaveAttachmentsViewModel.ResolveCaseFolderPath(picked);
if (string.IsNullOrEmpty(caseFolder))
{
MessageBox.Show(
ShowMessage(
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
@@ -533,15 +590,38 @@ namespace OutlookAddin.Services
catch (Exception ex)
{
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
MessageBox.Show(
ShowMessage(
"שמירת הקבצים נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
/// <summary>
/// Looks up the mail sender against the CRM (reusing the sidebar's
/// cached match) and, when it resolves to one or more linked cases,
/// seeds the save-attachments dialog so the already-identified client
/// case shows up pre-selected. Best-effort: swallows any error.
/// </summary>
private async Task TryPreselectSenderCaseAsync(SaveAttachmentsViewModel vm, string? senderEmail)
{
if (MatchingService == null || string.IsNullOrWhiteSpace(senderEmail)) return;
try
{
var match = await MatchingService.LookupAsync(senderEmail!).ConfigureAwait(true);
// Ambiguous (same email on several contacts) → don't guess; let
// the user search. A clean match with linked cases → seed them.
if (match == null || match.IsAmbiguous) return;
if (match.RecentCases == null || match.RecentCases.Count == 0) return;
vm.PreselectCases(match.RecentCases);
}
catch (Exception ex)
{
Logger.Warning(ex, "Sender-case preselect for save-attachments failed (non-fatal)");
}
}
private void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
{
string message;
MessageBoxImage icon;
@@ -558,7 +638,7 @@ namespace OutlookAddin.Services
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
}
MessageBox.Show(message, "Marcus-Law OutlookAddin", MessageBoxButton.OK, icon);
ShowMessage(message, MessageBoxButton.OK, icon);
}
/// <summary>
@@ -570,9 +650,8 @@ namespace OutlookAddin.Services
{
if (!IsConfigured)
{
MessageBox.Show(
ShowMessage(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -589,9 +668,8 @@ namespace OutlookAddin.Services
{
Logger.Error(ex, "LaunchComposeFromCaseByIdAsync failed for caseId={CaseId}", caseId);
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
MessageBox.Show(
ShowMessage(
"פתיחת המייל מתיק נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -643,13 +721,19 @@ namespace OutlookAddin.Services
/// current Explorer selection, opens the search dialog, files on success,
/// and stamps categories on the chosen MailItems.
/// </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)
{
var result = MessageBox.Show(
var result = ShowMessage(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Marcus-Law OutlookAddin",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes)
@@ -662,9 +746,8 @@ namespace OutlookAddin.Services
var mailItems = ReadCurrentSelection();
if (mailItems.Count == 0)
{
MessageBox.Show(
ShowMessage(
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -673,39 +756,49 @@ namespace OutlookAddin.Services
try
{
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,
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;
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;
Logger.Information(
"Auto-filing {Count} mail item(s) from sidebar to {Type}/{Id} ({Name})",
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
}
else
{
var firstEnvelope = envelopes[0];
Logger.Information(
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
{
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);
ShowMessage(
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-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);
@@ -722,6 +815,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()
{
var result = new List<Outlook.MailItem>();
@@ -853,7 +961,7 @@ namespace OutlookAddin.Services
}
}
private static void ShowSummary(FilingBatchSummary summary, FilingTarget target)
private void ShowSummary(FilingBatchSummary summary, FilingTarget target)
{
string message;
MessageBoxImage icon;
@@ -874,7 +982,7 @@ namespace OutlookAddin.Services
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
}
MessageBox.Show(message, "Marcus-Law OutlookAddin", MessageBoxButton.OK, icon);
ShowMessage(message, MessageBoxButton.OK, icon);
}
private static string? TryGetSenderName(Outlook.MailItem item)
+60 -19
View File
@@ -42,21 +42,7 @@ namespace OutlookAddin.Services
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
ContactEntity? primaryContact = null;
if (caseEntity.ContactsIds != null && caseEntity.ContactsIds.Count > 0)
{
try
{
primaryContact = await _client.GetContactAsync(
caseEntity.ContactsIds[0],
"id,name,emailAddress",
default).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load primary contact for case {CaseId}", caseId);
}
}
var recipientEmail = await ResolveRecipientEmailAsync(caseEntity, caseId).ConfigureAwait(true);
var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
@@ -65,14 +51,14 @@ namespace OutlookAddin.Services
try
{
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress))
if (!string.IsNullOrWhiteSpace(recipientEmail))
{
var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!))
if (!existingTo.Contains(recipientEmail!))
{
mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress
: existingTo + "; " + primaryContact.EmailAddress;
? recipientEmail
: existingTo + "; " + recipientEmail;
}
}
@@ -107,6 +93,61 @@ namespace OutlookAddin.Services
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id));
}
/// <summary>
/// Find a usable recipient email for the case. EspoCRM's Case.contactsIds
/// is a many-to-many bag (primary + co-counsel + witnesses + …) and the
/// first entry isn't guaranteed to be the actual client — and even when
/// it is, the contact may have no emailAddress stored. So walk the list
/// and return the first non-empty address, then fall back to the
/// linked Account's email.
/// </summary>
private async Task<string?> ResolveRecipientEmailAsync(CaseEntity caseEntity, string caseId)
{
if (caseEntity.ContactsIds != null)
{
foreach (var contactId in caseEntity.ContactsIds)
{
if (string.IsNullOrWhiteSpace(contactId)) continue;
try
{
var contact = await _client.GetContactAsync(
contactId,
"id,name,emailAddress,emailAddressData",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(contact?.EmailAddress))
{
return contact!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load contact {ContactId} for case {CaseId}", contactId, caseId);
}
}
}
if (!string.IsNullOrWhiteSpace(caseEntity.AccountId))
{
try
{
var account = await _client.GetAccountAsync(
caseEntity.AccountId!,
"id,name,emailAddress",
default).ConfigureAwait(true);
if (!string.IsNullOrWhiteSpace(account?.EmailAddress))
{
return account!.EmailAddress;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load account {AccountId} for case {CaseId}", caseEntity.AccountId, caseId);
}
}
return null;
}
public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);
@@ -109,7 +109,7 @@ namespace OutlookAddin.Services
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"); }
};
vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url);
+2 -2
View File
@@ -19,8 +19,8 @@ namespace OutlookAddin
catch (Exception ex)
{
System.Windows.MessageBox.Show(
"האתחול של תוסף Marcus-Law נכשל: " + ex.Message,
"Marcus-Law OutlookAddin",
"האתחול של תוסף Klear נכשל: " + ex.Message,
"Klear",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Error);
}