Compare commits

...

29 Commits

Author SHA1 Message Date
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
PointStar be5edc203c feat: in-app updater button + correct case-folder resolution
Two related improvements:

1. About tab → "בדוק עדכונים" button. Calls ClickOnce's
   ApplicationDeployment.CurrentDeployment.CheckForDetailedUpdate +
   Update on background threads, so the user can pull a new release
   immediately without leaving Outlook — no PowerShell, no
   CleanOnlineAppCache dance.

   Status messages cover the four real outcomes:
     • not deployed via ClickOnce (F5 / dev install) → explicit error
     • on the latest version → green "already up to date"
     • update available → progress text, then "installed — restart
       Outlook"
     • download / manifest / generic exception → red, with reason

2. Case folder resolution now matches the server. The previous client
   logic used Case.name (the lawsuit title, e.g. "מונאס אריאל נ' ביטוח
   לאומי") which produced "47-מונאס אריאל נ ביטוח לאומי". But
   NetworkStorageIntegration's buildEntityFolderName uses the primary
   contact's name from Case.contactsIds[0] → server actually creates
   "47-אריאל מונאס" for the same case, so the two diverged and our
   uploads went to a phantom folder.

   New flow on case selection:
     a) EnsureCaseSubfoldersAsync (creates server's canonical folders)
     b) GetCase with select=contactsIds + networkStorageFolderPath
     c) GetContactAsync on contactsIds[0] → grab Name
     d) ResolveCaseFolderPath(case, contactName) → exact match
     e) Cache result on vm.ResolvedCaseFolderPath; AddInHost reads it
        instead of recomputing.

   ResolveCaseFolderPath fallback order is now (1) the integration's
   stored path, (2) <number>-<primary contact name>, (3) <number>-<case
   name>, (4) the contact / case name alone — matching the server's
   buildEntityFolderName logic step by step.

Tests: 39 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:08:20 +03:00
PointStar 1788b5bbee fix(about): show real ClickOnce deployment version in Settings → About
The About tab was always showing "1.0.0" because it read the DLL's
AssemblyVersion, which we never bumped in source — even when the
ClickOnce manifest had been rolled to 1.1.0 by the CI tag.

Read System.Deployment.Application.ApplicationDeployment.CurrentDeployment
.CurrentVersion at runtime when the add-in is running under ClickOnce
(IsNetworkDeployed=true). That's the version the user actually
installed. Fall back to AssemblyVersion when dev-running under F5 (no
ClickOnce context). Adds System.Deployment GAC reference to the UI
project.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:59:19 +03:00
PointStar 8011d4e34c diag(attachments): post-upload "did the file actually arrive" check
The server reported successful uploads for the two PDFs into case 6a03166d…
("מונאס אריאל") but the user couldn't find the files on the share. Both
LocalFilesystemClient::uploadFile and WebDavClient::uploadFile return
true only on a real write, so something downstream is silently dropping
the payload — most likely a WebDAV proxy or a basePath mismatch
between what NetworkStorageIntegration thinks the storage is vs. what
the user is browsing.

Add a verification step right after the upload:

  1. POST /Attachment   →  attachmentId
  2. POST /NetworkStorage/action/upload  →  201 from server
  3. GET  /NetworkStorage/folderContents?path=<folder>
     • If our filename is in the listing → INF "verify present"
     • Else → WRN "expected X but NOT in listing. Currently in folder: […]"

The diagnostic line surfaces both halves of the puzzle in a single
log entry, so the next save attempt will tell us in plain text whether
the storage layer is the liar or the path is being normalised
somewhere unexpected.

Tests: 39 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:12 +03:00
PointStar 5242abb585 feat(attachments): rewire to Marcus-Law NetworkStorageIntegration
After reading the NetworkStorageIntegration repo I realized the previous
"save attachments" flow was wrong on every level — Marcus-Law doesn't
use EspoCRM's stock Document/DocumentFolder model. Folders are real
filesystem paths under the network share, named after the Case (format
"<number>-<primary contact name>") with four standard subfolders:
מסמכים, אסמכתאות, התכתבויות, כללי.

Rewire the save pipeline against the integration's real endpoints
(documented in routes.json on the server repo):

Core — three new IEspoCrmClient methods:
- EnsureCaseSubfoldersAsync(caseId)
    POST /NetworkStorage/action/createSubfolders
    Idempotent — creates case folder + defaults if missing.
- ListNetworkStorageFolderAsync(path)
    GET /NetworkStorage/folderContents?path=…
    Returns folders + files; we filter to type=folder for the dropdown.
- UploadAttachmentToNetworkStorageAsync(attachmentId, targetPath)
    POST /NetworkStorage/action/upload
    Moves the staged Attachment binary onto the share at <path>/<name>.

CaseEntity gets a new `networkStorageFolderPath` field (set by the
integration's hook on first Document save; empty until then).

NetworkStorageItem DTO captures the folder-listing rows.

AttachmentSaveService rewritten — no more Document creation, no more
DocumentFolder picker. The new contract is:
    SaveAsync(attachments, targetFolderPath)
Where targetFolderPath = "<case folder>[/subfolder]". For each
attachment: upload binary via standard /Attachment, then move via
NetworkStorage upload.

UI — SaveAttachmentsViewModel:
- Subfolder dropdown switched from DocumentFolder objects to strings.
- Static defaults loaded up-front:
    "(שורש התיק)" + 4 server-side defaults.
- When the user picks a case:
    1) hydrate Case (number + networkStorageFolderPath) via GetCase
    2) call EnsureCaseSubfoldersAsync to materialise standard folders
    3) listFolder at the case path to merge any custom subfolders into
       the dropdown.
- ResolveCaseFolderPath: use Case.networkStorageFolderPath if set,
  otherwise compute "<number>-<contact-name>" with a client-side
  SanitizeFolderSegment that mirrors LocalFilesystemClient::sanitizeFileName
  on the server (geresh/gershayim/quotes removed, illegal chars → hyphen).

AddInHost.LaunchSaveAttachmentsAsync:
- Composes <caseFolder>[/subfolder] from the VM choice.
- Pre-flight ensureCaseSubfolders as a safety net (in case the user
  submitted before the dialog's lazy refresh completed).
- Summary MessageBox now shows the resolved storage path instead of a
  bare entity name.

Tests: 39 passing. The build is clean across Core / UI / Tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:42:22 +03:00
PointStar 037d573e6e log: ListDocumentFoldersAsync prints folder count on success
When the case-scoped query fails (e.g. tenant rejects parentId in
where), the ViewModel falls back to ListDocumentFoldersAsync — but a
success path with N=0 was silent, leaving the user wondering why the
dropdown stayed empty. Add an INF line so the log shows whether
N folders came back, so 0-folder tenants are obvious from a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:22:10 +03:00
PointStar cb7d530b4c fix(attachments): map MIME from extension + don't treat 403 as auth
Two bugs found by reading the log carefully — both shipped in the
initial attachments feature:

1. MailItemExtractor sent every attachment with ContentType = "" and
   UploadAttachmentAsync defaulted that to "application/octet-stream".
   EspoCRM's attachment upload whitelist (config
   attachmentUpload.fileAcceptOptions) does not include octet-stream,
   so the server replied:
     403 (X-Status-Reason=Not allowed file type.)
   Add a GuessMimeType(fileName) helper to MailItemExtractor that maps
   common extensions (pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv,
   images, archives, email, audio/video) to canonical MIMEs. The
   octet-stream is now only used for truly unknown extensions.

2. 403 was being conflated with 401 in THREE places — EnsureSuccessAsync,
   the inline UploadAttachmentAsync handler, and the inline
   CreateDocumentAsync handler. Result: a single "Not allowed file
   type" rejection threw EspoCrmAuthorizationException, which
   AttachmentSaveService treats as a batch-level auth failure and
   stops processing remaining files. (Same bug would have killed
   FilingService and MatchingService for any non-auth 403 — e.g. an
   ACL miss on one record stopping a multi-mail file.)

   Only 401 is unambiguously an auth issue. 403 means "you ARE who
   you say you are, but this specific operation isn't allowed" —
   content rejected / ACL miss / role missing. Treat 403 as a normal
   HTTP error: log the body + X-Status-Reason and surface the message
   to the user without aborting the batch.

Also include the X-Status-Reason header in the generic
EspoCrmHttpException message everywhere, so any future "fileSizeTooBig"
or similar shows up in the user-visible error and not just the log.

Tests: 39 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:12:08 +03:00
PointStar 7924cda520 fix(documents): set publishDate on Document create (Marcus-Law requires it)
The server returned:
  400 {"messageTranslation":{"label":"validationFailure",
       "data":{"field":"publishDate","type":"required"}}}

Marcus-Law's EspoCRM install marks Document.publishDate as required.
By default the field is optional, but this tenant tightened it. Send
today's date (UTC, YYYY-MM-DD form) so the validation passes — the
user can still edit the published date in EspoCRM later if needed.

Also add the same verbose-error pattern to CreateDocumentAsync that we
already use on UploadAttachmentAsync: when the create returns non-2xx,
log the response body + X-Status-Reason header so any future required-
field mismatch shows up as a single readable line instead of
"שמירת הקבצים נכשלה".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:03:16 +03:00
PointStar 7ee9a00fc4 fix(attachments): match official EspoCRM API + folder fallback
Per the EspoCRM Attachment API doc (documentation.espocrm.com/development/api/attachment/),
the binary is sent in the "file" field as a data URI. The previous
extra "contents" alias was a legacy convention that strict tenants
reject with HTTP 400 (with an empty body, so the user only ever saw
"שמירת הקבצים נכשלה"). Drop "contents" — match the documented payload:

  {
    "name": "report.pdf",
    "type": "application/pdf",
    "role": "Attachment",
    "relatedType": "Document",
    "field": "file",
    "file": "data:application/pdf;base64,..."
  }

Folder dropdown: previously case-scoped only. The /Document filter by
parentId returned 400 on this tenant and case-scoped queries can be
fragile across versions. Now: try case-scoped first (so the most-
relevant folders surface); if it comes back empty or errors, fall back
to ListDocumentFoldersAsync (the full tenant list). The user always has
something to choose from, and the "(בלי תיקייה)" sentinel still works
as a "no folder" pick.

Tests: 39 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:51:46 +03:00
PointStar af8cf13443 fix(attachments): folder query + verbose upload diagnostics
Two fixes for the save-attachments flow:

1. ListFoldersUsedInCaseAsync no longer hits /Case/{id}/documents — that
   related-records endpoint requires a Case-to-Document link that
   EspoCRM doesn't ship by default, so it returned an empty list on
   every install. Switch to a direct Document list filtered by the
   polymorphic parent fields:
     GET /Document?where[0]=parentType=Case&where[1]=parentId=<id>
   That returns all documents linked to the case regardless of how the
   parent relationship is wired.

2. UploadAttachmentAsync now:
   - Emits BOTH "contents" and "file" payload fields with the
     data-URI base64 string. Different EspoCRM versions read from
     different keys; including both makes the call portable.
   - Logs the request shape (name, type, base64 length) before sending
     so server-side rejects are easier to attribute.
   - On a non-2xx response, logs the response body AND any
     X-Status-Reason header (EspoCRM convention for short error codes
     like "fileSizeIsTooLarge") so the user-visible exception finally
     includes a useful reason instead of an empty body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:42:38 +03:00
PointStar 26322c92ff feat(save-attachments): scope folder list to the selected case
The folder dropdown was showing every DocumentFolder in EspoCRM (global
list). The user expected "folders that exist in THIS case" — only the
folders already in use for the chosen case's documents.

Change: when the user picks a case in the SaveAttachments dialog, the
folder list refreshes by walking
  GET /Case/{id}/documents?select=id,folderId,folderName&maxSize=200
and deduping the folderId values it finds. EspoCRM denormalizes
folderName onto Document, so no second lookup is needed.

If a case has no documents yet, the dropdown shows just the sentinel
"(בלי תיקייה)" — same default behavior as before, but no more
unrelated folders from other cases.

Files:
- Core: DocumentEntity gets FolderName; IEspoCrmClient and EspoCrmClient
  get ListFoldersUsedInCaseAsync(caseId)
- UI: SaveAttachmentsViewModel re-fetches on SelectedCase change
  (LoadFoldersForCaseAsync); AddInHost no longer calls the old
  LoadFoldersAsync.

Tests: 39 still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:32:55 +03:00
PointStar c98fee7b5b fix(csproj): re-add klear-attach.png embed entry (VS save stripped it)
Visual Studio rewrote the legacy VSTO csproj on its next save and
dropped the EmbeddedResource entry for the paperclip icon — without
it the new SaveAttachments ribbon button has no image at runtime and
the file isn't shipped in the assembly. Add it back next to the four
other Klear icons.
2026-05-12 20:28:19 +03:00
PointStar 1a9ca2d2ad fix(ribbon): invalidate "שמור קבצים" on SelectionChange (button stays disabled)
The new SaveAttachments button rendered but stayed greyed out. Cause:
getEnabled callbacks are evaluated once when the ribbon loads and the
result is cached — Office only re-asks if you explicitly invalidate
the control. The previous SelectionChange handler invalidated only
FileToEspoCrm.

Collected the selection-dependent button IDs into a static array and
made the SelectionChange handler invalidate all of them. Adding more
context-sensitive buttons in the future is now a one-line edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:27:33 +03:00
PointStar b1f8e3e50b feat(attachments): "save attachments only" ribbon action
New workflow alongside "תייק ל-Klear": save just the attachments of a
mail to a Case in EspoCRM (no Email entity created), with an optional
DocumentFolder.

Core:
- AttachmentEntity, DocumentEntity, DocumentFolder DTOs
- IEspoCrmClient gets three new endpoints:
    UploadAttachmentAsync (POST /Attachment, data-URI base64, role=Attachment)
    CreateDocumentAsync   (POST /Document, fileId + parent + folderId)
    ListDocumentFoldersAsync (GET /DocumentFolder, returns envelope)
- AttachmentSaveService orchestrates per-attachment upload + Document
  create, short-circuits on EspoCrmAuthorizationException, aggregates
  per-file Saved/Failed/AuthRequired outcomes into AttachmentSaveSummary

UI:
- SaveAttachmentsDialog (RTL, card layout matching FileToCaseDialog):
  Case search + folder dropdown + per-attachment checkbox list
- SaveAttachmentsViewModel: debounced GlobalSearch filtered to Case,
  optional folder selector (with a "(no folder)" sentinel row),
  Attachments collection of AttachmentRowViewModel rows
- AttachmentRowViewModel: IsSelected checkbox + display name + size
  computed from base64 length

Host:
- AddInHost.LaunchSaveAttachmentsAsync(MailItem) — extracts on the STA
  via MailItemExtractor, fetches folders, shows the dialog (anchored to
  Outlook via AttachOwnerToOutlook), runs AttachmentSaveService on OK,
  reports a Hebrew MessageBox summary
- AttachmentSaveService field on AddInHost + dispose hook

Ribbon:
- New "שמור קבצים" button in the Marcus-Law group, between Klear and
  פרטי תיק. Enabled only when exactly one MailItem is selected and it
  has at least one attachment
- klear-attach.png paperclip icon (32x32, blue rounded square),
  embedded as a resource and resolved by the existing OnGetImage
  callback (new case in the switch)

Tests: 39 still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:12:46 +03:00
chaim 0fea393b81 dist: add packaged OutlookAddin-Install.zip for end-user distribution
Contains:
- Install-Marcus-Law-OutlookAddin.bat (self-elevating UAC launcher)
- 4-install-addin-full.ps1            (cert + VSTOInstaller all-in-one)
- codesign.cer                        (public Marcus-Law CodeSign cert)

Lawyers download the zip, extract, and double-click the .bat.
2026-05-11 18:06:52 +00:00
chaim 46955e2064 cdn: fix healthcheck (use 127.0.0.1, not localhost) and clear default nginx html
busybox wget in alpine resolves "localhost" to ::1 first, but our nginx
`listen 80` is IPv4-only, so the healthcheck got Connection refused and
Coolify marked the container running:unhealthy (which also made Traefik
skip its router and fall back to the lower-priority extension-platform
catch-all). Hard-coding 127.0.0.1 fixes that.

Also clear /usr/share/nginx/html before COPY so the base image's
50x.html/index.html don't leak into the published artifact set.
2026-05-11 17:47:26 +00:00
39 changed files with 2513 additions and 125 deletions
+15
View File
@@ -0,0 +1,15 @@
* text=auto
# ClickOnce publish output must be byte-identical to the hashes recorded
# in dll.manifest at publish time. Treat everything under Publish/ as
# binary so git's CRLF/LF normalization can't change the bytes between
# what VS published and what end-users download via setup.exe.
Publish/** -text
# Defense in depth — apply the same rule to ClickOnce extensions
# regardless of where they live.
*.deploy binary
*.manifest binary
*.vsto binary
*.application binary
*.pfx binary
+8 -2
View File
@@ -24,16 +24,22 @@ project.lock.json
project.fragment.lock.json
# 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
+10 -2
View File
@@ -1,4 +1,4 @@
# outlook-addin-cdn serves ClickOnce artifacts at platform.dev.marcus-law.co.il/outlook-addin/*
# outlook-addin-cdn -- serves ClickOnce artifacts at platform.dev.marcus-law.co.il/outlook-addin/*
#
# The publish/ directory (produced by the Windows job) is copied into the image
# along with a custom nginx.conf that maps the ClickOnce-specific MIME types.
@@ -10,6 +10,11 @@ FROM nginx:1.27-alpine
RUN rm -f /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/outlook-addin.conf
# Clear the stock nginx html dir so its 50x.html/index.html don't end up
# alongside our ClickOnce manifests.
RUN rm -rf /usr/share/nginx/html/*
# Coolify adds a StripPrefix middleware for the /outlook-addin path, so nginx
# sees requests at the root (/OutlookAddin.vsto, etc) -- copy files to the
# nginx root accordingly.
@@ -20,5 +25,8 @@ RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 80
# Use 127.0.0.1 explicitly -- busybox wget resolves "localhost" to ::1 first,
# but our nginx `listen 80` is IPv4-only, so localhost would refuse the
# connection and the container would be flagged unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/health || exit 1
CMD wget -qO- http://127.0.0.1/health || exit 1
BIN
View File
Binary file not shown.
+37 -4
View File
@@ -268,14 +268,47 @@ If the session is on the Linux dev server (`/home/chaim/espocrm-extensions/Outlo
---
## How releases work (current, 2026-05-24)
**Production install URL:** `https://platform.dev.marcus-law.co.il/outlook-addin/OutlookAddin.vsto`
**Release flow:**
1. Code change merged to `main` (push or PR-merge — no CI fires yet)
2. Bump `<ApplicationVersion>` in `src/OutlookAddin/OutlookAddin.csproj` and `AssemblyVersion` + `AssemblyFileVersion` in `src/OutlookAddin/Properties/AssemblyInfo.cs` to the next 4-part version (e.g. `1.2.1.0`). CI overrides `ApplicationVersion` from the tag at build time, but leaves AssemblyInfo alone — keep them in sync manually so the bundled DLL's FileVersion matches the manifest.
3. `git tag -a v1.2.1 -m "..." && git push --tags`
4. Gitea Actions workflow (`.gitea/workflows/build.yml`) fires:
- Imports the **real** codesign cert from secrets `CODESIGN_CERT_PFX_BASE64` + `CODESIGN_THUMBPRINT` (publicKeyToken `c68d2b4c25051c5b`, `CN=Marcus-Law OutlookAddin`)
- Patches csproj to swap the dev self-signed thumbprint (`2399E9BF…`) for the real one
- `msbuild /t:Publish` with `/p:InstallUrl=https://platform.dev.marcus-law.co.il/outlook-addin/` and `/p:ApplicationVersion=<tag>.0`
- Uploads `publish.tar.gz` to Gitea generic registry (`outlook-addin-publish/<version>/publish.tar.gz`)
- Linux job downloads the tar.gz, builds a Docker image `outlook-addin-cdn:latest`, pushes to Gitea registry, calls Coolify to redeploy the CDN container
- Posts to Mattermost channel `git-verelasim` with the install URL on success / a failure link on failure
5. Lawyers' installed Add-in checks the deployment manifest at the install URL every 7 days (configured in csproj `<UpdateInterval>7</UpdateInterval>`) or whenever they click "בדוק עדכונים" in Settings → אודות.
**Semver policy:** PATCH for bug fixes (unilateral), MINOR for visible features (unilateral when warranted), MAJOR only with Chaim's explicit approval.
**Branding (`KlearBranding` repo):** the product is presented to lawyers as **Klear** — title bars, MessageBox titles, ribbon group label, About header all say "Klear". The logo is mirrored from `espocrm-extensions/KlearBranding/files/client/custom/img/logo-klear.png` into `src/OutlookAddin.UI/Resources/logo-klear.png` and embedded as a WPF Resource. Brand accent color: `#121d2e` (dark navy).
**What stays MarcusLaw under the hood (deliberate — preserves auto-update across the rebrand):**
- ClickOnce app identity (`AssemblyName=MarcusLaw.OutlookAddin`)
- `%APPDATA%\MarcusLaw\OutlookAddin\settings.json` and `creds.dat`
- `%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs\`
- `DpapiCredentialStore.EntropySeed = "MarcusLaw.OutlookAddin.v1"`
- Code namespaces `MarcusLaw.OutlookAddin.*`
- Named pipe `MarcusLaw.OutlookAddin`
Changing any of these would orphan all existing installs and require a coordinated uninstall+reinstall on every lawyer's PC plus an entropy migration for DPAPI-encrypted credentials. See the LegalCRM rebrand misadventure in git history for what *not* to do.
**Known traps when releasing:**
- VS Publish wizard (Project Properties → Publish) **rewrites** csproj at every click — flips `<AutoIncrementApplicationRevision>` back to `true`, resets `<ApplicationVersion>` to whatever's in the UI, sometimes deletes prior `Application Files/<...>/` bundles. **Don't use it.** Tag and let CI do it.
- There's an old `Publish/` directory in git history that was a parallel manual-release channel via `gitea.../raw/branch/main/Publish/`. It's **not the production install URL** — that's platform.dev. The folder was dropped in v1.2.0.
## Things deliberately deferred
| 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 |
---
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Models
{
/// <summary>
/// Response from POST /api/v1/Attachment — the server returns at least
/// the new attachment's id, which we then thread into a Document create.
/// </summary>
public sealed class AttachmentEntity
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("type")]
public string? Type { get; set; }
}
}
@@ -23,6 +23,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
[JsonPropertyName("status")]
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; }
@@ -31,5 +38,12 @@ namespace MarcusLaw.OutlookAddin.Core.Models
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; set; }
// Set by Marcus-Law's NetworkStorageIntegration hook on first
// Document save. Empty until a document has been written through
// EspoCRM. When empty we compute it client-side from number +
// primary contact name.
[JsonPropertyName("networkStorageFolderPath")]
public string? NetworkStorageFolderPath { get; set; }
}
}
@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Models
{
public sealed class DocumentEntity
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("fileId")]
public string? FileId { get; set; }
[JsonPropertyName("folderId")]
public string? FolderId { get; set; }
[JsonPropertyName("folderName")]
public string? FolderName { get; set; }
[JsonPropertyName("parentType")]
public string? ParentType { get; set; }
[JsonPropertyName("parentId")]
public string? ParentId { get; set; }
}
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Models
{
/// <summary>
/// A row from /api/v1/DocumentFolder — used to populate the folder
/// dropdown in the "Save attachments" dialog. EspoCRM document folders
/// are organizational labels independent of the document's parent link.
/// </summary>
public sealed class DocumentFolder
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("parentId")]
public string? ParentId { get; set; }
[JsonPropertyName("order")]
public int? Order { get; set; }
}
}
@@ -17,6 +17,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
public int CandidateCount { get; set; }
/// <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;
}
}
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace MarcusLaw.OutlookAddin.Core.Models
{
/// <summary>
/// One row from GET /NetworkStorage/folderContents — folder or file.
/// </summary>
public sealed class NetworkStorageItem
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("path")]
public string Path { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty; // "folder" or "file"
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("modified")]
public string? Modified { get; set; }
public bool IsFolder => string.Equals(Type, "folder", System.StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
using Serilog;
namespace MarcusLaw.OutlookAddin.Core.Services
{
public sealed class AttachmentSaveService : IAttachmentSaveService
{
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
public AttachmentSaveService(IEspoCrmClient client, ILogger logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AttachmentSaveSummary> SaveAsync(
IReadOnlyList<EspoAttachment> attachments,
string targetFolderPath,
CancellationToken cancellationToken = default)
{
if (attachments == null) throw new ArgumentNullException(nameof(attachments));
if (string.IsNullOrWhiteSpace(targetFolderPath)) throw new ArgumentException("targetFolderPath required", nameof(targetFolderPath));
var summary = new AttachmentSaveSummary();
if (attachments.Count == 0) return summary;
var trimmedFolder = targetFolderPath.TrimEnd('/');
foreach (var att in attachments)
{
cancellationToken.ThrowIfCancellationRequested();
if (summary.AuthRequired)
{
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.AuthRequired,
ErrorMessage = "Skipped — earlier upload rejected by EspoCRM"
});
continue;
}
string? attachmentId = null;
Exception? uploadException = null;
// Step 1: create the EspoCRM Attachment (binary stored in
// data/upload/<id>; not yet on the network share). Auth
// failures here are terminal — skip remaining items.
try
{
var uploaded = await _client.UploadAttachmentAsync(
att.Name, att.ContentType, att.ContentBase64, cancellationToken).ConfigureAwait(false);
attachmentId = uploaded.Id;
}
catch (EspoCrmAuthorizationException ex)
{
_logger.Warning(ex, "Attachment save aborted: EspoCRM rejected credentials");
summary.AuthRequired = true;
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.AuthRequired,
ErrorMessage = ex.Message
});
continue;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
// No attachment ID means we have nothing on the share —
// a hard fail.
_logger.Warning(ex, "Attachment upload (Step 1) failed for {Name}", att.Name);
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.Failed,
ErrorMessage = ex.Message
});
continue;
}
// Step 2: ask the NetworkStorageIntegration to place the
// uploaded blob under <targetFolderPath>. The 15s Polly
// timeout sometimes fires while the server is still
// writing the file — capture the exception but don't
// decide the outcome yet.
try
{
await _client.UploadAttachmentToNetworkStorageAsync(
attachmentId, trimmedFolder, cancellationToken).ConfigureAwait(false);
}
catch (EspoCrmAuthorizationException ex)
{
_logger.Warning(ex, "Attachment save aborted on Step 2: EspoCRM rejected credentials");
summary.AuthRequired = true;
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.AuthRequired,
ErrorMessage = ex.Message
});
continue;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
uploadException = ex;
_logger.Warning(ex,
"Attachment NetworkStorage upload (Step 2) reported failure for {Name}; will verify disk before declaring failure",
att.Name);
}
// Step 3: verify on disk. Authoritative — if the file is
// there, the save succeeded regardless of what Step 2
// reported (Polly timeouts during large writes are common).
var verified = await VerifyArrivedWithRetryAsync(
att.Name, trimmedFolder, uploadException != null, cancellationToken).ConfigureAwait(false);
if (verified)
{
_logger.Information(
"Saved attachment {Name} → {Path} (attachmentId={AttachmentId}, uploadException={HadException})",
att.Name, trimmedFolder, attachmentId, uploadException != null);
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.Saved,
AttachmentId = attachmentId
});
}
else
{
var message = uploadException?.Message
?? "Server reported success but the file is not on the share.";
_logger.Warning(
"Attachment save Failed for {Name}: not present after verify (uploadException={Msg})",
att.Name, message);
summary.Items.Add(new AttachmentSaveItemResult
{
FileName = att.Name,
Outcome = AttachmentSaveOutcome.Failed,
ErrorMessage = message
});
}
}
return summary;
}
/// <summary>
/// Authoritative post-upload check. Returns true if the file is on
/// disk under <paramref name="folderPath"/>. When the upload call
/// raised (typically Polly's 15s timeout on a still-writing server),
/// we retry the listing a few times so the server gets a chance
/// to finish.
/// </summary>
private async Task<bool> VerifyArrivedWithRetryAsync(string expectedName, string folderPath, bool afterFailure, CancellationToken ct)
{
// When the upload appeared to succeed, one check is plenty — the
// file is already there. When it appeared to fail, the server
// may still be writing; give it a few extra seconds.
var attempts = afterFailure ? 4 : 1;
var delay = TimeSpan.FromSeconds(2);
for (int i = 0; i < attempts; i++)
{
ct.ThrowIfCancellationRequested();
if (i > 0) await Task.Delay(delay, ct).ConfigureAwait(false);
if (await IsPresentAsync(expectedName, folderPath, ct).ConfigureAwait(false))
{
if (i > 0)
{
_logger.Information(
"Post-upload verify: '{Expected}' appeared under '{Path}' on attempt {Attempt}/{Total} despite upload exception",
expectedName, folderPath, i + 1, attempts);
}
return true;
}
}
return false;
}
private async Task<bool> IsPresentAsync(string expectedName, string folderPath, CancellationToken ct)
{
try
{
var items = await _client.ListNetworkStorageFolderAsync(folderPath, ct).ConfigureAwait(false);
var trimmedExpected = expectedName.Trim().TrimStart('-', ' ').TrimEnd('-', ' ');
foreach (var it in items)
{
if (it.IsFolder) continue;
if (string.Equals(it.Name, expectedName, StringComparison.Ordinal) ||
string.Equals(it.Name, trimmedExpected, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
catch (Exception ex)
{
_logger.Warning(ex, "Post-upload listing under '{Path}' failed", folderPath);
return false;
}
}
}
}
+322 -2
View File
@@ -202,6 +202,320 @@ namespace MarcusLaw.OutlookAddin.Core.Services
public Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default)
=> GetJsonAsync<UserInfo>("App/user", cancellationToken);
public async Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentException("fileName required", nameof(fileName));
if (string.IsNullOrWhiteSpace(base64Content)) throw new ArgumentException("base64Content required", nameof(base64Content));
var mime = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
var dataUri = "data:" + mime + ";base64," + base64Content;
// Per the official EspoCRM API docs (POST /Attachment), the binary
// is sent in the "file" field as a data-URI. The previous extra
// "contents" alias was an undocumented legacy that strict tenants
// reject with 400.
var payload = new Dictionary<string, object?>
{
["name"] = fileName,
["type"] = mime,
["role"] = "Attachment",
["relatedType"] = "Document",
["field"] = "file",
["file"] = dataUri
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
_logger.Information(
"UploadAttachment POST /Attachment (name={Name}, type={Type}, base64Length={Length})",
fileName, mime, base64Content.Length);
var response = await ExecuteAsync(HttpMethod.Post, "Attachment", json, cancellationToken).ConfigureAwait(false);
try
{
if (!response.IsSuccessStatusCode)
{
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = string.Join(",", response.Headers.TryGetValues("X-Status-Reason", out var v) ? v : Array.Empty<string>());
_logger.Warning(
"UploadAttachment failed: {Status} (X-Status-Reason={Reason}) body={Body}",
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
// 401 only = auth. 403 here is content/permission, not creds.
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException(
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
}
throw new EspoCrmHttpException(response.StatusCode, errBody,
$"EspoCRM Attachment upload returned {(int)response.StatusCode} {response.StatusCode}" +
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
}
var body = await ReadBodyAsync(response).ConfigureAwait(false);
return JsonSerializer.Deserialize<AttachmentEntity>(body, JsonOptions)
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty AttachmentEntity from EspoCRM");
}
finally
{
response.Dispose();
}
}
public async Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("name required", nameof(name));
if (string.IsNullOrWhiteSpace(attachmentFileId)) throw new ArgumentException("attachmentFileId required", nameof(attachmentFileId));
// Marcus-Law's EspoCRM has `publishDate` configured as required on
// Document. The field accepts a plain date (YYYY-MM-DD). Default
// to today; the user can edit it later in EspoCRM if needed.
var todayIso = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
var payload = new Dictionary<string, object?>
{
["name"] = name,
["fileId"] = attachmentFileId,
["status"] = "Active",
["publishDate"] = todayIso
};
if (!string.IsNullOrWhiteSpace(parentType)) payload["parentType"] = parentType;
if (!string.IsNullOrWhiteSpace(parentId)) payload["parentId"] = parentId;
if (!string.IsNullOrWhiteSpace(folderId)) payload["folderId"] = folderId;
var json = JsonSerializer.Serialize(payload, JsonOptions);
_logger.Information(
"CreateDocument POST /Document (name={Name}, fileId={FileId}, parent={ParentType}/{ParentId}, folder={FolderId})",
name, attachmentFileId, parentType, parentId, folderId ?? "(none)");
var response = await ExecuteAsync(HttpMethod.Post, "Document", json, cancellationToken).ConfigureAwait(false);
try
{
if (!response.IsSuccessStatusCode)
{
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
? string.Join(",", v) : "(none)";
_logger.Warning(
"CreateDocument failed: {Status} (X-Status-Reason={Reason}) body={Body}",
response.StatusCode, reason, string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException(
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
}
throw new EspoCrmHttpException(response.StatusCode, errBody,
$"EspoCRM Document create returned {(int)response.StatusCode} {response.StatusCode}" +
(string.IsNullOrEmpty(reason) ? "" : " (reason: " + reason + ")"));
}
var body = await ReadBodyAsync(response).ConfigureAwait(false);
return JsonSerializer.Deserialize<DocumentEntity>(body, JsonOptions)
?? throw new EspoCrmHttpException(response.StatusCode, body, "Empty DocumentEntity from EspoCRM");
}
finally
{
response.Dispose();
}
}
public async Task<List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(caseId)) return new List<DocumentFolder>();
// Some EspoCRM installs reject filtering by polymorphic parentType
// with a 400. ParentId is a regular string field, and an ID alone
// is unique enough — we'll filter just by that. We additionally
// verify parentType client-side to be safe.
var path =
"Document?select=" + Uri.EscapeDataString("id,folderId,folderName,parentType") +
"&where[0][type]=equals" +
"&where[0][attribute]=parentId" +
"&where[0][value]=" + Uri.EscapeDataString(caseId) +
"&maxSize=200";
// Hand-rolled error capture (instead of GetJsonAsync) so a 400 from
// a picky tenant ends up in the log with body + X-Status-Reason.
HttpResponseMessage? response = null;
try
{
response = await ExecuteAsync(HttpMethod.Get, path, null, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errBody = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
? string.Join(",", v) : "(none)";
_logger.Warning(
"ListFoldersUsedInCase({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
caseId, response.StatusCode, reason,
string.IsNullOrEmpty(errBody) ? "(empty)" : Truncate(errBody, 500));
return new List<DocumentFolder>();
}
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var parsed = JsonSerializer.Deserialize<EspoListResponse<DocumentEntity>>(body, JsonOptions)
?? new EspoListResponse<DocumentEntity>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var folders = new List<DocumentFolder>();
foreach (var doc in parsed.List)
{
// Defensive: if the server returns docs from other parents
// (shouldn't, but ID collisions are theoretically possible),
// drop them.
if (!string.IsNullOrEmpty(doc.ParentType) &&
!string.Equals(doc.ParentType, "Case", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrEmpty(doc.FolderId)) continue;
if (!seen.Add(doc.FolderId!)) continue;
folders.Add(new DocumentFolder
{
Id = doc.FolderId!,
Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName!
});
}
_logger.Information(
"ListFoldersUsedInCase({CaseId}) → {DocumentCount} documents, {FolderCount} distinct folders",
caseId, parsed.List.Count, folders.Count);
return folders;
}
catch (Exception ex)
{
_logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId);
return new List<DocumentFolder>();
}
finally
{
response?.Dispose();
}
}
public async Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var payload = new Dictionary<string, object?>
{
["entityType"] = "Case",
["entityId"] = caseId
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/createSubfolders", json, cancellationToken).ConfigureAwait(false);
try
{
if (!response.IsSuccessStatusCode)
{
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
_logger.Warning(
"EnsureCaseSubfolders({CaseId}): {Status} (X-Status-Reason={Reason}) body={Body}",
caseId, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
}
// Don't throw — folder creation is best-effort. Upload will fail
// with its own clear error if the parent really isn't there.
return;
}
_logger.Information("EnsureCaseSubfolders({CaseId}) OK", caseId);
}
finally
{
response.Dispose();
}
}
public async Task<List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default)
{
var query = "NetworkStorage/folderContents";
if (!string.IsNullOrEmpty(path))
{
query += "?path=" + Uri.EscapeDataString(path);
}
HttpResponseMessage? response = null;
try
{
response = await ExecuteAsync(HttpMethod.Get, query, null, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
_logger.Warning(
"ListNetworkStorageFolder({Path}): {Status} (X-Status-Reason={Reason}) body={Body}",
path, response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
return new List<NetworkStorageItem>();
}
var json = await ReadBodyAsync(response).ConfigureAwait(false);
var items = JsonSerializer.Deserialize<List<NetworkStorageItem>>(json, JsonOptions);
_logger.Information("ListNetworkStorageFolder({Path}) → {Count} entries", path, items?.Count ?? 0);
return items ?? new List<NetworkStorageItem>();
}
catch (Exception ex)
{
_logger.Warning(ex, "ListNetworkStorageFolderAsync({Path}) failed", path);
return new List<NetworkStorageItem>();
}
finally
{
response?.Dispose();
}
}
public async Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(attachmentId)) throw new ArgumentException("attachmentId required", nameof(attachmentId));
if (string.IsNullOrWhiteSpace(targetPath)) throw new ArgumentException("targetPath required", nameof(targetPath));
var payload = new Dictionary<string, object?>
{
["attachmentId"] = attachmentId,
["path"] = targetPath
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
_logger.Information(
"UploadToNetworkStorage POST /NetworkStorage/action/upload (attachmentId={AttachmentId}, path={Path})",
attachmentId, targetPath);
var response = await ExecuteAsync(HttpMethod.Post, "NetworkStorage/action/upload", json, cancellationToken).ConfigureAwait(false);
try
{
if (!response.IsSuccessStatusCode)
{
var body = await ReadBodyAsync(response).ConfigureAwait(false);
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v) ? string.Join(",", v) : "(none)";
_logger.Warning(
"UploadToNetworkStorage failed: {Status} (X-Status-Reason={Reason}) body={Body}",
response.StatusCode, reason, string.IsNullOrEmpty(body) ? "(empty)" : Truncate(body, 500));
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException("EspoCRM rejected credentials. Re-enter API key.");
}
throw new EspoCrmHttpException(response.StatusCode, body,
$"NetworkStorage upload returned {(int)response.StatusCode} {response.StatusCode}" +
(string.IsNullOrEmpty(reason) || reason == "(none)" ? "" : " (reason: " + reason + ")"));
}
}
finally
{
response.Dispose();
}
}
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
{
try
{
var result = await GetJsonAsync<EspoListResponse<DocumentFolder>>(
"DocumentFolder?orderBy=name&order=asc&maxSize=200",
cancellationToken).ConfigureAwait(false);
_logger.Information(
"ListDocumentFoldersAsync → {Count} folders in tenant",
result?.List?.Count ?? 0);
return result ?? new EspoListResponse<DocumentFolder>();
}
catch (Exception ex)
{
_logger.Warning(ex, "ListDocumentFoldersAsync failed; returning empty list");
return new EspoListResponse<DocumentFolder>();
}
}
public async Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(contactId)) throw new ArgumentException("contactId required", nameof(contactId));
@@ -343,14 +657,20 @@ namespace MarcusLaw.OutlookAddin.Core.Services
if (response.IsSuccessStatusCode) return;
var body = await ReadBodyAsync(response).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
// Only 401 is unambiguously an authentication problem. 403 in
// EspoCRM is "you are who you say you are, but THIS operation
// isn't allowed" — content type rejected, ACL miss, required
// role missing, etc. Conflating them aborts whole batches.
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
throw new EspoCrmAuthorizationException(
$"EspoCRM rejected credentials ({(int)response.StatusCode} {response.StatusCode}). Re-enter API key.");
}
var reason = response.Headers.TryGetValues("X-Status-Reason", out var v)
? " (reason: " + string.Join(",", v) + ")" : string.Empty;
throw new EspoCrmHttpException(response.StatusCode, body,
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}: {Truncate(body, 500)}");
$"EspoCRM returned {(int)response.StatusCode} {response.StatusCode}{reason}: {Truncate(body, 500)}");
}
private static async Task<string> ReadBodyAsync(HttpResponseMessage response)
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
namespace MarcusLaw.OutlookAddin.Core.Services
{
public interface IAttachmentSaveService
{
/// <summary>
/// Uploads each attachment as an EspoCRM Attachment, then asks
/// Marcus-Law's NetworkStorage integration to place the file under
/// <paramref name="targetFolderPath"/> on the shared storage. No
/// Document entity is created — these are raw files dropped into
/// the case's filesystem folder.
/// </summary>
/// <param name="attachments">files to save</param>
/// <param name="targetFolderPath">
/// Full relative folder path on storage, e.g.
/// <c>"30-מרדכי שמביקו/אסמכתאות"</c>. The case folder + optional
/// subfolder. Filename is appended internally.
/// </param>
Task<AttachmentSaveSummary> SaveAsync(
IReadOnlyList<EspoAttachment> attachments,
string targetFolderPath,
CancellationToken cancellationToken = default);
}
public sealed class AttachmentSaveSummary
{
public List<AttachmentSaveItemResult> Items { get; set; } = new List<AttachmentSaveItemResult>();
public bool AuthRequired { get; set; }
public int SavedCount
{
get
{
var n = 0;
foreach (var it in Items)
{
if (it.Outcome == AttachmentSaveOutcome.Saved) n++;
}
return n;
}
}
public int FailedCount
{
get
{
var n = 0;
foreach (var it in Items)
{
if (it.Outcome == AttachmentSaveOutcome.Failed) n++;
}
return n;
}
}
}
public sealed class AttachmentSaveItemResult
{
public string FileName { get; set; } = string.Empty;
public AttachmentSaveOutcome Outcome { get; set; }
public string? AttachmentId { get; set; }
public string? ErrorMessage { get; set; }
}
public enum AttachmentSaveOutcome
{
Saved,
Failed,
AuthRequired
}
}
@@ -15,5 +15,16 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Task<ContactEntity> CreateContactAsync(string name, string? emailAddress = null, CancellationToken cancellationToken = default);
Task<EspoListResponse<CaseEntity>> ListCasesForContactAsync(string contactId, int maxSize = 5, CancellationToken cancellationToken = default);
Task<UserInfo> TestConnectionAsync(CancellationToken cancellationToken = default);
Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default);
Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
Task<System.Collections.Generic.List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default);
// Marcus-Law NetworkStorageIntegration endpoints (filesystem-based
// folder structure under the case folder).
Task EnsureCaseSubfoldersAsync(string caseId, CancellationToken cancellationToken = default);
Task<System.Collections.Generic.List<NetworkStorageItem>> ListNetworkStorageFolderAsync(string path, CancellationToken cancellationToken = default);
Task UploadAttachmentToNetworkStorageAsync(string attachmentId, string targetPath, CancellationToken cancellationToken = default);
}
}
@@ -13,6 +13,15 @@ namespace MarcusLaw.OutlookAddin.Core.Services
/// </summary>
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"] = "פתוח",
@@ -0,0 +1,230 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.SaveAttachmentsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:MarcusLaw.OutlookAddin.UI.Converters"
Title="שמור קבצים מצורפים ל-Klear"
Height="560" Width="620"
MinHeight="420" MinWidth="500"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
ResizeMode="CanResize"
Background="#F8FAFC"
FontFamily="Segoe UI, David, Frank Ruehl, Arial">
<Window.Resources>
<conv:CaseStatusToHebrewConverter x:Key="CaseStatusHe" />
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<SolidColorBrush x:Key="Primary" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryHover" Color="#1D4ED8" />
<SolidColorBrush x:Key="Border" Color="#E2E8F0" />
<SolidColorBrush x:Key="Muted" Color="#64748B" />
<SolidColorBrush x:Key="CardBg" Color="White" />
<SolidColorBrush x:Key="HoverBg" Color="#F1F5F9" />
<SolidColorBrush x:Key="SelectedBg" Color="#DBEAFE" />
<Style x:Key="Card" TargetType="Border">
<Setter Property="Background" Value="{StaticResource CardBg}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
</Style>
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource Primary}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="22,8" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="MinWidth" Value="110" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="5" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource PrimaryHover}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Background" Value="#94A3B8" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SecondaryButton" TargetType="Button">
<Setter Property="Background" Value="White" />
<Setter Property="Foreground" Value="#0F172A" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="20,8" />
<Setter Property="MinWidth" Value="110" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource HoverBg}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Margin="16" x:Name="RootGrid">
<Grid.Resources>
<Style x:Key="CaseGrid" TargetType="DataGrid">
<Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HorizontalGridLinesBrush" Value="#E2E8F0" />
<Setter Property="RowBackground" Value="White" />
<Setter Property="AlternatingRowBackground" Value="#F8FAFC" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CanUserAddRows" Value="False" />
<Setter Property="CanUserDeleteRows" Value="False" />
<Setter Property="CanUserResizeRows" Value="False" />
<Setter Property="CanUserReorderColumns" Value="False" />
<Setter Property="SelectionMode" Value="Single" />
<Setter Property="SelectionUnit" Value="FullRow" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="RowHeight" Value="30" />
</Style>
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Search -->
<Border Grid.Row="0" Style="{StaticResource Card}" Padding="10,8" Margin="0,0,0,8">
<DockPanel LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="🔍" VerticalAlignment="Center" FontSize="16" Margin="0,0,8,0" />
<TextBox x:Name="SearchBox"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
BorderThickness="0"
Background="Transparent"
FontSize="14"
VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Cases -->
<Border Grid.Row="1" Style="{StaticResource Card}">
<DataGrid ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
Style="{StaticResource CaseGrid}">
<DataGrid.Columns>
<DataGridTextColumn Header="מס׳ תיק" Binding="{Binding Number}" Width="80" />
<DataGridTextColumn Header="שם" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="מס׳ בבית משפט" Binding="{Binding CourtCaseNumber}" Width="140" />
<DataGridTextColumn Header="סטטוס" Binding="{Binding Status, Converter={StaticResource CaseStatusHe}}" Width="110" />
</DataGrid.Columns>
</DataGrid>
</Border>
<!-- Folder -->
<DockPanel Grid.Row="2" Margin="0,10,0,0" LastChildFill="True">
<TextBlock DockPanel.Dock="Left" Text="תיקייה:" VerticalAlignment="Center"
Margin="0,0,8,0" Foreground="{StaticResource Muted}" FontSize="12" />
<TextBlock DockPanel.Dock="Right" Text="טוען…" VerticalAlignment="Center"
Margin="8,0,0,0" Foreground="{StaticResource Muted}" FontSize="12"
Visibility="{Binding IsLoadingSubfolders, Converter={StaticResource BoolToVis}}" />
<ComboBox ItemsSource="{Binding Subfolders}"
SelectedItem="{Binding SelectedSubfolder}"
Padding="6,4">
<ComboBox.Style>
<Style TargetType="ComboBox">
<Style.Triggers>
<DataTrigger Binding="{Binding IsLoadingSubfolders}" Value="True">
<Setter Property="IsEnabled" Value="False" />
</DataTrigger>
</Style.Triggers>
</Style>
</ComboBox.Style>
</ComboBox>
</DockPanel>
<!-- Attachments header -->
<TextBlock Grid.Row="3" Margin="0,12,0,4" Foreground="{StaticResource Muted}" FontSize="12">
<Run Text="קבצים מצורפים (" /><Run Text="{Binding Attachments.Count, Mode=OneWay}" /><Run Text=")" />
</TextBlock>
<!-- Attachments list -->
<Border Grid.Row="4" Style="{StaticResource Card}" MaxHeight="160">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Attachments}" Margin="6">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,3" LastChildFill="True">
<CheckBox DockPanel.Dock="Right" IsChecked="{Binding IsSelected, Mode=TwoWay}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding SizeDisplay}"
DockPanel.Dock="Left"
Foreground="{StaticResource Muted}"
FontSize="11"
VerticalAlignment="Center"
Margin="8,0,0,0" />
<TextBlock Text="{Binding Extension}"
DockPanel.Dock="Left"
Foreground="{StaticResource Muted}"
FontSize="12"
VerticalAlignment="Center"
Margin="6,0,0,0" />
<TextBox Text="{Binding BaseName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding IsSelected}"
VerticalAlignment="Center"
BorderThickness="1"
BorderBrush="{StaticResource Border}"
Padding="6,3"
Margin="8,0,0,0"
ToolTip="לחץ כדי לערוך את שם הקובץ (הסיומת נשמרת אוטומטית)" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- Action row -->
<DockPanel Grid.Row="5" Margin="0,12,0,0" LastChildFill="True">
<TextBlock DockPanel.Dock="Right"
Text="{Binding StatusMessage}"
VerticalAlignment="Center"
Margin="0,0,8,0"
Foreground="{StaticResource Muted}"
FontSize="12"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Command="{Binding SubmitCommand}"
IsDefault="True"
Content="שמור"
Style="{StaticResource PrimaryButton}"
Margin="0,0,8,0" />
<Button Command="{Binding CancelCommand}"
IsCancel="True"
Content="ביטול"
Style="{StaticResource SecondaryButton}" />
</StackPanel>
</DockPanel>
</Grid>
</Window>
@@ -0,0 +1,36 @@
using System;
using System.Windows;
using MarcusLaw.OutlookAddin.UI.ViewModels;
namespace MarcusLaw.OutlookAddin.UI.Dialogs
{
public partial class SaveAttachmentsDialog : Window
{
private SaveAttachmentsViewModel? _viewModel;
public SaveAttachmentsDialog()
{
InitializeComponent();
Loaded += (_, __) => SearchBox.Focus();
}
public SaveAttachmentsDialog(SaveAttachmentsViewModel viewModel) : this()
{
AttachViewModel(viewModel);
}
public void AttachViewModel(SaveAttachmentsViewModel viewModel)
{
if (_viewModel != null) _viewModel.RequestClose -= OnRequestClose;
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
DataContext = _viewModel;
_viewModel.RequestClose += OnRequestClose;
}
private void OnRequestClose(object? sender, EventArgs e)
{
DialogResult = _viewModel?.DialogResult;
Close();
}
}
}
@@ -98,13 +98,47 @@
<TabItem Header="אודות">
<StackPanel Margin="16">
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
<TextBlock Text="תוסף Klear ל-Microsoft Outlook" Margin="0,4,0,12" />
<TextBlock Text="{Binding VersionLine}" Foreground="Gray" />
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap"
Text="לוגים: %LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
<TextBlock TextWrapping="Wrap"
Text="הגדרות: %APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
<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"
Command="{Binding CheckForUpdateCommand}"
Content="בדוק עדכונים"
Padding="14,4"
MinWidth="120" />
</DockPanel>
<TextBlock Text="{Binding UpdateStatus}"
Foreground="{Binding UpdateStatusBrush}"
TextWrapping="Wrap"
Margin="0,4,0,0" />
<TextBlock Margin="0,16,0,0" TextWrapping="Wrap">
<Run Text="לוגים: " />
<Hyperlink Tag="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs"
Click="OnOpenFolderHyperlink"
ToolTip="פתח את התיקייה ב-Explorer">
<Run Text="%LOCALAPPDATA%\MarcusLaw\OutlookAddin\logs" />
</Hyperlink>
</TextBlock>
<TextBlock TextWrapping="Wrap">
<Run Text="הגדרות: " />
<Hyperlink Tag="%APPDATA%\MarcusLaw\OutlookAddin"
Click="OnOpenFolderHyperlink"
ToolTip="פתח את התיקייה ב-Explorer">
<Run Text="%APPDATA%\MarcusLaw\OutlookAddin\settings.json" />
</Hyperlink>
</TextBlock>
</StackPanel>
</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);
}
}
}
}
@@ -18,10 +18,15 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Deployment" />
</ItemGroup>
<ItemGroup>
<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

@@ -0,0 +1,71 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Models;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
public sealed partial class AttachmentRowViewModel : ObservableObject
{
public EspoAttachment Data { get; }
[ObservableProperty]
private bool isSelected = true;
/// <summary>
/// Frozen at construction from the original filename. The user
/// edits only the base name; the extension always tags along.
/// </summary>
public string Extension { get; }
/// <summary>
/// Editable base name without the extension. Setting this rewrites
/// <see cref="EspoAttachment.Name"/> as &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; }
public AttachmentRowViewModel(EspoAttachment data)
{
Data = data;
Extension = Path.GetExtension(data.Name) ?? string.Empty;
SizeDisplay = ComputeSize(data.ContentBase64);
}
private static string ComputeSize(string base64)
{
if (string.IsNullOrEmpty(base64)) return string.Empty;
// Base64 expands binary 4-for-3, ignoring padding chars.
var padding = 0;
if (base64.Length > 0 && base64[base64.Length - 1] == '=') padding++;
if (base64.Length > 1 && base64[base64.Length - 2] == '=') padding++;
var bytes = (long)((base64.Length * 3) / 4) - padding;
return FormatBytes(bytes);
}
private static string FormatBytes(long bytes)
{
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024.0).ToString("F1") + " KB";
return (bytes / (1024.0 * 1024.0)).ToString("F1") + " MB";
}
}
}
@@ -30,6 +30,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IMatchingService _matchingService;
private readonly 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;
}
@@ -0,0 +1,358 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
/// <summary>
/// Picks a Case + optional subfolder (filesystem-style, via Marcus-Law's
/// NetworkStorageIntegration) for saving raw attachment files.
/// </summary>
public sealed partial class SaveAttachmentsViewModel : ObservableObject
{
// Default subfolders the integration creates under each Case folder.
// Mirrors Resources/metadata/app/networkStorage.json on the server.
private static readonly string[] DefaultCaseSubfolders =
{
"מסמכים",
"אסמכתאות",
"התכתבויות",
"כללי"
};
private const string RootSentinel = "(שורש התיק)";
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
private CancellationTokenSource? _searchCts;
[ObservableProperty]
private string searchText = string.Empty;
[ObservableProperty]
private CaseEntity? selectedCase;
[ObservableProperty]
private string? selectedSubfolder;
[ObservableProperty]
private string? statusMessage;
[ObservableProperty]
private bool isLoadingSubfolders;
/// <summary>
/// Resolved server-side case folder path for the currently
/// selected case (e.g. "47-אריאל מונאס"). Populated by
/// <see cref="RefreshSubfoldersForSelectedCaseAsync"/> after the
/// case + primary contact have been fetched. Empty until then.
/// </summary>
public string? ResolvedCaseFolderPath { get; private set; }
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
public bool? DialogResult { get; private set; }
public IReadOnlyList<EspoAttachment> SelectedAttachments
{
get
{
var list = new List<EspoAttachment>();
foreach (var row in Attachments)
{
if (row.IsSelected) list.Add(row.Data);
}
return list;
}
}
/// <summary>
/// Subfolder string the user picked, or null to save to the case
/// folder root.
/// </summary>
public string? ChosenSubfolderName =>
string.IsNullOrEmpty(SelectedSubfolder) ||
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
? null
: SelectedSubfolder;
public CaseEntity? PickedCase => SelectedCase;
public event EventHandler? RequestClose;
public SaveAttachmentsViewModel(
IEspoCrmClient client,
ILogger logger,
IEnumerable<EspoAttachment> attachments)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ResetSubfolders();
foreach (var att in attachments)
{
Attachments.Add(new AttachmentRowViewModel(att));
}
}
private void ResetSubfolders()
{
Subfolders.Clear();
Subfolders.Add(RootSentinel);
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
SelectedSubfolder = Subfolders[0];
}
partial void OnSearchTextChanged(string value)
{
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
_ = SearchAsync(value, _searchCts.Token);
}
partial void OnSelectedCaseChanged(CaseEntity? value)
{
SubmitCommand.NotifyCanExecuteChanged();
_ = RefreshSubfoldersForSelectedCaseAsync();
}
/// <summary>
/// Refreshes the subfolder dropdown to merge static defaults with
/// the actual folders that already exist on the network share for
/// the chosen case. Also resolves <see cref="ResolvedCaseFolderPath"/>
/// using the same logic as the server (primary contact's name, not
/// the case's lawsuit title).
/// </summary>
public async Task RefreshSubfoldersForSelectedCaseAsync()
{
ResetSubfolders();
ResolvedCaseFolderPath = null;
var c = SelectedCase;
if (c == null || string.IsNullOrEmpty(c.Id)) return;
IsLoadingSubfolders = true;
try
{
await RefreshSubfoldersCoreAsync(c).ConfigureAwait(true);
}
finally
{
IsLoadingSubfolders = false;
}
}
private async Task RefreshSubfoldersCoreAsync(CaseEntity c)
{
try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
string? primaryContactName = null;
try
{
var detail = await _client.GetCaseAsync(
c.Id, "id,name,number,contactsIds,networkStorageFolderPath", default).ConfigureAwait(true);
c.Number = detail.Number;
c.Name = detail.Name;
c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath;
c.ContactsIds = detail.ContactsIds ?? new List<string>();
if (c.ContactsIds.Count > 0)
{
try
{
var contact = await _client.GetContactAsync(c.ContactsIds[0], "id,name", default).ConfigureAwait(true);
primaryContactName = contact?.Name;
}
catch (Exception ex) { _logger.Warning(ex, "Primary contact fetch failed for case {CaseId}", c.Id); }
}
}
catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); }
var folderPath = ResolveCaseFolderPath(c, primaryContactName);
ResolvedCaseFolderPath = folderPath;
_logger.Information(
"Resolved case folder for {CaseId} (number={Number}, contact={Contact}, stored={Stored}) → {Path}",
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
if (string.IsNullOrEmpty(folderPath)) return;
try
{
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
foreach (var item in items)
{
if (!item.IsFolder) continue;
if (Subfolders.Contains(item.Name)) continue;
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
Subfolders.Add(item.Name);
}
}
catch (Exception ex)
{
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
}
}
/// <summary>
/// Mirrors NetworkDocumentService::buildEntityFolderName for Case
/// on the server side. Fallback order:
/// 1) Case.networkStorageFolderPath (if the integration's hook
/// has set it — most authoritative)
/// 2) "&lt;number&gt;-&lt;primary contact name&gt;" ← server's recipe
/// 3) "&lt;number&gt;-&lt;case lawsuit name&gt;"
/// 4) contact / case name on its own
/// </summary>
public static string ResolveCaseFolderPath(CaseEntity c, string? primaryContactName = null)
{
if (c == null) return string.Empty;
if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath))
{
return c.NetworkStorageFolderPath!.TrimEnd('/');
}
var nameForFolder = !string.IsNullOrWhiteSpace(primaryContactName)
? primaryContactName!
: (c.Name ?? string.Empty);
var raw = !string.IsNullOrWhiteSpace(c.Number)
? $"{c.Number}-{nameForFolder}"
: nameForFolder;
return SanitizeFolderSegment(raw);
}
/// <summary>
/// Mirrors LocalFilesystemClient::sanitizeFileName on the server side
/// so client-computed paths match what the integration writes.
/// </summary>
public static string SanitizeFolderSegment(string name)
{
if (string.IsNullOrWhiteSpace(name)) return string.Empty;
foreach (var c in new[] { '׳', '״', '\'', '"', '', '', '“', '”' })
{
name = name.Replace(c.ToString(), string.Empty);
}
foreach (var c in new[] { '/', '\\', ':', '*', '?', '<', '>', '|' })
{
name = name.Replace(c, '-');
}
while (name.Contains("--")) name = name.Replace("--", "-");
while (name.Contains(" ")) name = name.Replace(" ", " ");
name = name.Replace("-.", ".");
return name.Trim(' ', '-');
}
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
{
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
if (string.IsNullOrEmpty(hit.Id)) return row;
try
{
var detail = await _client.GetCaseAsync(
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
row.Number = detail.Number;
row.Status = detail.Status;
row.CourtCaseNumber = detail.CourtCaseNumber;
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.Warning(ex, "Hydrating case {CaseId} for picker failed", hit.Id);
}
return row;
}
private async Task SearchAsync(string query, CancellationToken ct)
{
try
{
await Task.Delay(250, ct).ConfigureAwait(true);
var trimmed = (query ?? string.Empty).Trim();
if (trimmed.Length < 2)
{
Cases.Clear();
StatusMessage = null;
return;
}
var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
Cases.Clear();
var caseHits = new List<EspoEntityRef>();
foreach (var hit in search.List)
{
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
caseHits.Add(hit);
}
if (caseHits.Count == 0)
{
StatusMessage = "אין תוצאות מסוג תיק (Case)";
return;
}
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
foreach (var hit in caseHits)
{
detailTasks.Add(LoadCaseRowAsync(hit, ct));
}
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
foreach (var row in details)
{
if (row != null) Cases.Add(row);
}
StatusMessage = $"נמצאו {Cases.Count} תיקים";
}
catch (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException)
{
StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
}
catch (Exception ex)
{
_logger.Warning(ex, "SaveAttachments search failed");
StatusMessage = "שגיאת חיפוש: " + ex.Message;
}
}
[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit()
{
DialogResult = true;
RequestClose?.Invoke(this, EventArgs.Empty);
}
private bool CanSubmit()
{
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return false;
foreach (var row in Attachments)
{
if (row.IsSelected) return true;
}
return false;
}
[RelayCommand]
private void Cancel()
{
DialogResult = false;
RequestClose?.Invoke(this, EventArgs.Empty);
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Media;
@@ -40,6 +41,15 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
[ObservableProperty]
private Brush statusBrush = NeutralBrush;
[ObservableProperty]
private string? updateStatus;
[ObservableProperty]
private Brush updateStatusBrush = NeutralBrush;
[ObservableProperty]
private bool isCheckingForUpdate;
public string InitialApiKey { get; }
public string VersionLine { get; }
@@ -70,8 +80,56 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
Username = creds?.Username ?? string.Empty;
InitialApiKey = creds?.ApiKey ?? string.Empty;
var version = typeof(SettingsViewModel).Assembly.GetName().Version?.ToString() ?? "1.0.0";
VersionLine = $"גרסה {version}";
VersionLine = $"גרסה {ResolveDisplayVersion()}";
}
/// <summary>
/// Picks the most accurate version to surface in the About tab:
/// the ClickOnce activation version when the add-in is installed
/// over the wire (auto-updated to the real deployment version),
/// else the assembly's AssemblyVersion (which is set per-source
/// and may lag behind the deployed ClickOnce manifest).
/// </summary>
private static string ResolveDisplayVersion()
{
Version? v = null;
try
{
if (System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{
v = System.Deployment.Application.ApplicationDeployment
.CurrentDeployment.CurrentVersion;
}
}
catch
{
// Not running under ClickOnce (dev / F5), or System.Deployment
// is not available — fall through to the AssemblyVersion.
}
// The host VSTO assembly is where versioning policy lives
// (csproj + AssemblyInfo). The UI assembly is SDK-style with
// no <Version>, so it always reports 1.0.0.0 — don't fall back
// to it. Walk loaded assemblies for the host by name suffix.
if (v == null)
{
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var name = asm.GetName().Name;
if (name != null && name.EndsWith(".OutlookAddin", StringComparison.Ordinal))
{
v = asm.GetName().Version;
break;
}
}
}
v ??= typeof(SettingsViewModel).Assembly.GetName().Version;
if (v == null) return "1.0.0";
// ClickOnce stores 4 components; project policy is MAJOR.MINOR.PATCH —
// hide the trailing revision when it's 0.
return v.Revision == 0
? $"{v.Major}.{v.Minor}.{v.Build}"
: v.ToString();
}
/// <summary>
@@ -105,6 +163,197 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private static string WatchedKey(string storeId, string path) => storeId + "|" + path;
[RelayCommand(CanExecute = nameof(CanCheckForUpdate))]
private async Task CheckForUpdateAsync()
{
IsCheckingForUpdate = true;
UpdateStatusBrush = NeutralBrush;
UpdateStatus = "בודק עדכונים…";
try
{
if (!System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed)
{
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "התוסף לא מותקן דרך ClickOnce — אין כאן עדכון אוטומטי.";
return;
}
var deployment = System.Deployment.Application.ApplicationDeployment.CurrentDeployment;
var currentVersion = deployment.CurrentVersion;
var manifestUri = deployment.UpdateLocation;
// ApplicationDeployment.CheckForDetailedUpdate triggers a
// TrustManager re-evaluation which throws TrustNotGranted
// inside a hosted VSTO process even when the publisher and
// permissions are unchanged. Bypass it by fetching the
// deployment manifest directly and reading the version.
Version? availableVersion = null;
try
{
availableVersion = await Task.Run(() => FetchManifestVersion(manifestUri)).ConfigureAwait(true);
}
catch (Exception fetchEx)
{
_logger.Warning(fetchEx, "CheckForUpdate: manifest fetch failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "שגיאה בבדיקת השרת: " + fetchEx.Message;
return;
}
if (availableVersion == null)
{
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "לא ניתן לקרוא את גרסת השרת מ-" + manifestUri;
return;
}
if (availableVersion <= currentVersion)
{
UpdateStatusBrush = SuccessBrush;
UpdateStatus = "אתה על הגרסה האחרונה (" + currentVersion + ").";
return;
}
// VSTO add-ins cannot be updated programmatically from inside
// a running Outlook — ApplicationDeployment.Update() throws.
// Don't shell-launch the .vsto URL either: Chrome / Edge will
// download it to disk, and the resulting file has a different
// security zone than the deployment manifest, which fails with
// InvalidDeploymentException ("no matching security zones").
// Call VSTOInstaller.exe directly so the URL is processed in
// the Internet zone, matching the manifest.
var installerPath = FindVstoInstaller();
if (installerPath != null)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
installerPath, "/I \"" + manifestUri + "\"")
{
UseShellExecute = false
});
UpdateStatusBrush = SuccessBrush;
UpdateStatus = $"זמינה גרסה {availableVersion} — אשר את ההתקנה בחלון שנפתח, ואז סגור ופתח את Outlook.";
}
catch (Exception launchEx)
{
_logger.Warning(launchEx, "CheckForUpdate: VSTOInstaller launch failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = $"זמינה גרסה {availableVersion} — סגור ופתח את Outlook כדי להתקין אוטומטית.";
}
}
else
{
// VSTOInstaller not at the canonical path — VSTO runtime
// still picks up new manifests on Outlook startup, so
// ask the user to restart instead of attempting a hack.
UpdateStatusBrush = SuccessBrush;
UpdateStatus = $"זמינה גרסה {availableVersion} — סגור ופתח את Outlook כדי להתקין אוטומטית.";
}
return;
}
catch (System.Deployment.Application.DeploymentDownloadException ex)
{
_logger.Warning(ex, "CheckForUpdate: download failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "הורדת העדכון נכשלה: " + ex.Message;
}
catch (System.Deployment.Application.InvalidDeploymentException ex)
{
_logger.Warning(ex, "CheckForUpdate: invalid deployment manifest");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "ה-deployment manifest על השרת לא תקין: " + ex.Message;
}
catch (Exception ex)
{
_logger.Warning(ex, "CheckForUpdate failed");
UpdateStatusBrush = ErrorBrush;
UpdateStatus = "שגיאה בבדיקת עדכונים: " + ex.Message;
}
finally
{
IsCheckingForUpdate = false;
}
}
/// <summary>
/// Reads the top-level &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;
}
}
/// <summary>
/// Locates VSTOInstaller.exe on the machine. The path is stable
/// for VSTO 4.0 (Office 2010+) — falls back to a registry lookup
/// if the default install location was customised.
/// </summary>
private static string? FindVstoInstaller()
{
string[] candidates =
{
@"C:\Program Files (x86)\Common Files\Microsoft Shared\VSTO\10.0\VSTOInstaller.exe",
@"C:\Program Files\Common Files\Microsoft Shared\VSTO\10.0\VSTOInstaller.exe"
};
foreach (var p in candidates)
{
if (System.IO.File.Exists(p)) return p;
}
// Registry fallback: HKCR\VstoFile\shell\Open\command stores
// the verb VSTOInstaller registers for the .vsto extension.
try
{
using (var key = Microsoft.Win32.Registry.ClassesRoot.OpenSubKey(@"VstoFile\shell\Open\command"))
{
var cmd = key?.GetValue(null) as string;
if (!string.IsNullOrEmpty(cmd))
{
// Value looks like: "C:\...\VSTOInstaller.exe" "%1"
var firstQuote = cmd!.IndexOf('"');
if (firstQuote == 0)
{
var endQuote = cmd.IndexOf('"', 1);
if (endQuote > 1)
{
var exe = cmd.Substring(1, endQuote - 1);
if (System.IO.File.Exists(exe)) return exe;
}
}
}
}
}
catch { /* registry access denied or schema differs — fall through */ }
return null;
}
private bool CanCheckForUpdate() => !IsCheckingForUpdate;
partial void OnIsCheckingForUpdateChanged(bool value)
{
CheckForUpdateCommand.NotifyCanExecuteChanged();
}
[RelayCommand]
private async Task TestConnectionAsync()
{
+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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 647 B

+7 -4
View File
@@ -31,13 +31,13 @@
<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.4</ApplicationVersion>
<AutoIncrementApplicationRevision>true</AutoIncrementApplicationRevision>
<ApplicationVersion>1.2.5.0</ApplicationVersion>
<AutoIncrementApplicationRevision>false</AutoIncrementApplicationRevision>
<UpdateEnabled>true</UpdateEnabled>
<UpdateInterval>7</UpdateInterval>
<UpdateIntervalUnits>days</UpdateIntervalUnits>
@@ -270,6 +270,9 @@
<EmbeddedResource Include="Images\klear-compose.png">
<LogicalName>OutlookAddin.Images.klear-compose.png</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Images\klear-attach.png">
<LogicalName>OutlookAddin.Images.klear-attach.png</LogicalName>
</EmbeddedResource>
<Compile Include="Services\AddInHost.cs">
<SubType>Code</SubType>
</Compile>
+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.5.0")]
[assembly: AssemblyFileVersion("1.2.5.0")]
+58 -2
View File
@@ -19,7 +19,15 @@ namespace OutlookAddin.Ribbon
{
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
private const string FileButtonId = "FileToEspoCrm";
// Every button whose `getEnabled` depends on the current Explorer
// selection must be re-evaluated on every SelectionChange — Office
// caches the initial result otherwise and the button stays stuck.
private static readonly string[] SelectionDependentButtons =
{
"FileToEspoCrm",
"SaveAttachments"
};
private IRibbonUI? _explorerRibbon;
private IRibbonUI? _inspectorRibbon;
@@ -45,7 +53,7 @@ namespace OutlookAddin.Ribbon
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
if (explorer != null)
{
explorer.SelectionChange += () => _explorerRibbon?.InvalidateControl(FileButtonId);
explorer.SelectionChange += InvalidateSelectionDependentButtons;
}
}
catch
@@ -54,6 +62,16 @@ namespace OutlookAddin.Ribbon
}
}
private void InvalidateSelectionDependentButtons()
{
var ribbon = _explorerRibbon;
if (ribbon == null) return;
foreach (var id in SelectionDependentButtons)
{
try { ribbon.InvalidateControl(id); } catch { /* ignore */ }
}
}
public void OnInspectorRibbonLoad(IRibbonUI ribbon)
{
_inspectorRibbon = ribbon;
@@ -92,6 +110,43 @@ namespace OutlookAddin.Ribbon
Globals.ThisAddIn.AddInHost.ShowSidebar();
}
public bool OnGetSaveAttachmentsEnabled(IRibbonControl control)
{
// Enabled only when EXACTLY one MailItem is selected AND it has
// at least one attachment. Doing multi-mail save would conflict
// with the per-file checkbox UX in the dialog.
try
{
var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
if (selection == null || selection.Count != 1) return false;
if (selection[1] is not Outlook.MailItem mail) return false;
var attachments = mail.Attachments;
if (attachments == null) return false;
return attachments.Count > 0;
}
catch
{
return false;
}
}
public void OnSaveAttachments(IRibbonControl control)
{
try
{
var selection = Globals.ThisAddIn.Application.ActiveExplorer()?.Selection;
if (selection == null || selection.Count != 1) return;
if (selection[1] is Outlook.MailItem mail)
{
_ = Globals.ThisAddIn.AddInHost.LaunchSaveAttachmentsAsync(mail);
}
}
catch (Exception ex)
{
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnSaveAttachments failed");
}
}
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
{
// Show only on outgoing (compose) mail inspectors.
@@ -150,6 +205,7 @@ namespace OutlookAddin.Ribbon
switch (control.Id)
{
case "FileToEspoCrm": fileName = "klear-file.png"; break;
case "SaveAttachments": 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;
+9 -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"
@@ -12,6 +12,14 @@
getImage="OnGetImage"
onAction="OnFileToEspoCrm"
getEnabled="OnGetFileToEspoCrmEnabled" />
<button id="SaveAttachments"
label="שמור קבצים"
screentip="שמור את הקבצים המצורפים בלבד"
supertip="מעלה את הקבצים המצורפים של המייל הנבחר ל-Klear כ-Documents מקושרים לתיק שתבחר, בלי לתייק את המייל עצמו."
size="large"
getImage="OnGetImage"
onAction="OnSaveAttachments"
getEnabled="OnGetSaveAttachmentsEnabled" />
<button id="ShowSidebar"
label="פרטי תיק"
screentip="הצג את סרגל פרטי התיק"
+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"
+203 -37
View File
@@ -38,6 +38,7 @@ namespace OutlookAddin.Services
public IEspoCrmClient? CrmClient { get; private set; }
public IFilingService? FilingService { get; private set; }
public IMatchingService? MatchingService { get; private set; }
public IAttachmentSaveService? AttachmentSaveService { get; private set; }
public ComposeService? ComposeService { get; private set; }
private readonly Outlook.Application _outlookApp;
@@ -353,6 +354,7 @@ namespace OutlookAddin.Services
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
ComposeService = new ComposeService(CrmClient, Logger);
AttachmentSaveService = new AttachmentSaveService(CrmClient, Logger);
_folderResolver ??= new FolderResolver(_outlookApp, Logger);
_folderWatcher = new FolderWatcher(
_outlookApp,
@@ -387,6 +389,7 @@ namespace OutlookAddin.Services
FilingService = null;
MatchingService = null;
ComposeService = null;
AttachmentSaveService = null;
}
/// <summary>
@@ -401,7 +404,7 @@ namespace OutlookAddin.Services
{
MessageBox.Show(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -422,12 +425,142 @@ namespace OutlookAddin.Services
Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
MessageBox.Show(
"טעינת התיק נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
/// <summary>
/// Save-attachments-only flow. The user clicks the ribbon's "שמור
/// קבצים" button while standing on a mail with one or more
/// attachments. We extract the attachments (no Email entity
/// created), let the user pick a Case + optional DocumentFolder,
/// then upload each attachment + create a Document linked to the
/// case.
/// </summary>
public async Task LaunchSaveAttachmentsAsync(Outlook.MailItem mail)
{
if (mail == null) return;
if (!IsConfigured)
{
var result = MessageBox.Show(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Klear",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes) LaunchSettings();
if (!IsConfigured) return;
}
// Extract on the STA — MailItemExtractor handles all COM access
// and gives us a self-contained envelope.
MailEnvelope envelope;
try
{
envelope = MailItemExtractor.Extract(mail);
}
catch (Exception ex)
{
Logger.Error(ex, "LaunchSaveAttachmentsAsync: extract failed");
MessageBox.Show(
"קריאת הקבצים המצורפים נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
return;
}
if (envelope.Attachments.Count == 0)
{
MessageBox.Show(
"אין במייל הזה קבצים מצורפים לשמירה.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
// Subfolders are populated lazily once the user picks a case;
// the default static set is already pre-loaded.
var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog);
var ok = dialog.ShowDialog();
if (ok != true) return;
var picked = vm.PickedCase;
var selected = vm.SelectedAttachments;
if (picked == null || string.IsNullOrEmpty(picked.Id) || selected.Count == 0) return;
// Prefer the path the VM resolved when the user picked the case
// (it already fetched the primary contact and applied the
// server's naming convention). Fall back to the no-contact
// resolve only if that step somehow didn't run.
var caseFolder = !string.IsNullOrWhiteSpace(vm.ResolvedCaseFolderPath)
? vm.ResolvedCaseFolderPath!
: SaveAttachmentsViewModel.ResolveCaseFolderPath(picked);
if (string.IsNullOrEmpty(caseFolder))
{
MessageBox.Show(
"לא ניתן לחשב את נתיב התיקייה של התיק. ייתכן שהתיק עוד לא הוגדר ב-Klear.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
var subfolder = vm.ChosenSubfolderName;
var targetFolderPath = string.IsNullOrEmpty(subfolder)
? caseFolder
: caseFolder + "/" + subfolder;
try
{
Logger.Information(
"Saving {Count} attachment(s) to Case/{Id} ({Name}) → {Path}",
selected.Count, picked.Id, picked.Name, targetFolderPath);
// Safety net: ensure the case folder + defaults exist on
// disk before the per-file uploads (the dialog may have
// been submitted before the prior ensure call finished).
try { await CrmClient!.EnsureCaseSubfoldersAsync(picked.Id).ConfigureAwait(true); }
catch (Exception ex) { Logger.Warning(ex, "Pre-upload ensure failed; continuing anyway"); }
var summary = await AttachmentSaveService!.SaveAsync(selected, targetFolderPath).ConfigureAwait(true);
ShowAttachmentSummary(summary, picked.Name ?? picked.Id, targetFolderPath);
}
catch (Exception ex)
{
Logger.Error(ex, "LaunchSaveAttachmentsAsync failed");
MessageBox.Show(
"שמירת הקבצים נכשלה: " + ex.Message,
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
private static void ShowAttachmentSummary(AttachmentSaveSummary summary, string caseLabel, string targetPath)
{
string message;
MessageBoxImage icon;
if (summary.AuthRequired)
{
message = "מפתח ה-API נדחה. יש לעדכן את ההגדרות ולנסות שוב.";
icon = MessageBoxImage.Warning;
}
else
{
var parts = new List<string>();
if (summary.SavedCount > 0) parts.Add($"נשמרו {summary.SavedCount} קבצים אל \"{caseLabel}\" ({targetPath})");
if (summary.FailedCount > 0) parts.Add($"{summary.FailedCount} נכשלו");
message = parts.Count == 0 ? "לא נשמרו פריטים." : string.Join(" · ", parts);
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
}
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon);
}
/// <summary>
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
/// Creates a brand new MailItem, populates it with case details, then
@@ -439,7 +572,7 @@ namespace OutlookAddin.Services
{
MessageBox.Show(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -458,7 +591,7 @@ namespace OutlookAddin.Services
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
MessageBox.Show(
"פתיחת המייל מתיק נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
@@ -510,13 +643,20 @@ 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(
"התוסף עדיין לא מוגדר. לפתוח את חלון ההגדרות עכשיו?",
"Marcus-Law OutlookAddin",
"Klear",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes)
@@ -531,7 +671,7 @@ namespace OutlookAddin.Services
{
MessageBox.Show(
"לא נבחר מייל לתיוק. סמן מייל אחד או יותר ונסה שוב.",
"Marcus-Law OutlookAddin",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@@ -540,39 +680,50 @@ 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);
MessageBox.Show(
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
"Klear",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
Logger.Information(
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
}
var summary = await FilingService!.FileAsync(envelopes, target).ConfigureAwait(true);
@@ -589,6 +740,21 @@ namespace OutlookAddin.Services
}
}
// Sidebar's "תייק לתיק הזה" makes a hard promise about which case
// gets the mail — only honour it when the match resolved a single
// linked case. With 0 or 2+ cases there is no unambiguous "this",
// and the caller falls back to the manual search dialog.
private static FilingTarget? TryResolveSidebarAutoTarget(MatchResult? match)
{
if (match?.RecentCases == null || match.RecentCases.Count != 1) return null;
var c = match.RecentCases[0];
if (string.IsNullOrWhiteSpace(c.Id)) return null;
var label = !string.IsNullOrWhiteSpace(c.Number)
? $"[{c.Number}] {c.Name}"
: (c.Name ?? c.Id);
return new FilingTarget("Case", c.Id, label);
}
private List<Outlook.MailItem> ReadCurrentSelection()
{
var result = new List<Outlook.MailItem>();
@@ -741,7 +907,7 @@ namespace OutlookAddin.Services
icon = summary.FailedCount > 0 ? MessageBoxImage.Warning : MessageBoxImage.Information;
}
MessageBox.Show(message, "Marcus-Law OutlookAddin", MessageBoxButton.OK, icon);
MessageBox.Show(message, "Klear", MessageBoxButton.OK, icon);
}
private static string? TryGetSenderName(Outlook.MailItem item)
+67 -1
View File
@@ -98,9 +98,11 @@ namespace OutlookAddin.Services
{
att.SaveAsFile(tempPath);
var bytes = File.ReadAllBytes(tempPath);
var name = att.FileName ?? "attachment.bin";
env.Attachments.Add(new EspoAttachment
{
Name = att.FileName ?? "attachment.bin",
Name = name,
ContentType = GuessMimeType(name),
ContentBase64 = Convert.ToBase64String(bytes)
});
}
@@ -171,6 +173,70 @@ namespace OutlookAddin.Services
return name;
}
/// <summary>
/// Picks a sane MIME type from the file extension. EspoCRM's default
/// attachment-upload config rejects "application/octet-stream" with
/// "Not allowed file type", so we map common Office / image / archive
/// extensions to canonical MIMEs before posting.
/// </summary>
private static string GuessMimeType(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return "application/octet-stream";
var ext = Path.GetExtension(fileName).ToLowerInvariant();
switch (ext)
{
// Office (legacy + OOXML)
case ".pdf": return "application/pdf";
case ".doc": return "application/msword";
case ".docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
case ".xls": return "application/vnd.ms-excel";
case ".xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case ".ppt": return "application/vnd.ms-powerpoint";
case ".pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
case ".rtf": return "application/rtf";
case ".odt": return "application/vnd.oasis.opendocument.text";
case ".ods": return "application/vnd.oasis.opendocument.spreadsheet";
// Text
case ".txt": return "text/plain";
case ".csv": return "text/csv";
case ".log": return "text/plain";
case ".htm":
case ".html": return "text/html";
case ".xml": return "application/xml";
case ".json": return "application/json";
// Images
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".gif": return "image/gif";
case ".bmp": return "image/bmp";
case ".tif":
case ".tiff": return "image/tiff";
case ".webp": return "image/webp";
case ".svg": return "image/svg+xml";
// Archives
case ".zip": return "application/zip";
case ".rar": return "application/x-rar-compressed";
case ".7z": return "application/x-7z-compressed";
case ".gz": return "application/gzip";
case ".tar": return "application/x-tar";
// Email
case ".eml": return "message/rfc822";
case ".msg": return "application/vnd.ms-outlook";
// Audio / video — rare in legal mail but cheap to handle
case ".mp3": return "audio/mpeg";
case ".wav": return "audio/wav";
case ".mp4": return "video/mp4";
default: return "application/octet-stream";
}
}
private static T? SafeGet<T>(Func<T> getter) where T : class
{
try { return getter(); } catch { return null; }
@@ -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);
}