Compare commits

...
121 Commits
Author SHA1 Message Date
mdx-1andeSlider 35f0cb8d20 feat(mailsync): FetchMailFolder — integration-layer walk for ETL consumers
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 24s
Tests / Test (Go stable) (pull_request) Successful in 40s
Adds the high-level mail folder walk that sync pipelines need on top of
the raw mail API (list -> get -> download-attachment), so consumers stop
re-implementing it against private client copies.

  type MailSyncMessage struct { ID, Folder, Subject, From, Date, IsNew,
                                HasAttachments, Attachments }
  type MailSyncAttachment struct { ID, Name, Size, Body }
  func (c *Client) FetchMailFolder(ctx, folderID, MailSyncOptions)
                                   ([]MailSyncMessage, error)

Options: Limit / StartIndex for checkpointed walks, FetchBodies to
eagerly download attachment bytes via download.ashx (session-cookie path).

Hydration details:
- list items may omit the attachment array; when hasAttachments is set
  the full record is fetched and its attachments merged
- attachment ids accepted from id/fileId/attachmentId variants
- timestamps parsed from RFC3339 (any fractional digits) and
  second-precision forms

This is the first step of the 2dph integration layer (#1): the brain's
mail-ingest pipeline can now drop its private OOClient copy and consume
this canonical walk directly.

Tests: httptest-backed coverage for pagination, hydration with
full-record fallback, body download incl. auth-cookie requirement,
Limit/StartIndex windows, timestamp parsing.
2026-08-31 10:05:47 +01:00
eSlider ca5c85a4e2 Merge pull request 'chore(release): v0.16.0 — put-xlsx (excelize) (#13)' (#14) from chore/release-v0.16.0#13 into main
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 4s
Tests / Test (Go stable) (push) Successful in 23s
Tests / Test (Go 1.25) (push) Successful in 24s
2026-08-30 13:44:47 +01:00
eSlider 220c7e8265 chore(release): v0.16.0 — put-xlsx (excelize) (#13)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 8s
Tests / Test (Go 1.25) (pull_request) Successful in 2m3s
Tests / Test (Go stable) (pull_request) Successful in 2m5s
2026-08-30 13:41:51 +01:00
eSlider ff4c0481ba Merge pull request 'feat(docs): put-xlsx с excelize — multi-sheet бюджеты (cutover-portugal)' (#12) from feat/docs-put-xlsx into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 5s
Tests / Test (Go 1.25) (push) Successful in 2m10s
Tests / Test (Go stable) (push) Successful in 2m12s
2026-08-30 13:41:25 +01:00
eSliderandCursor 68445b0dfd feat(docs): put-xlsx with excelize cutover workbook
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go 1.25) (pull_request) Successful in 1m59s
Tests / Test (Go stable) (pull_request) Successful in 2m5s
Add internal/xlspipe (github.com/xuri/excelize/v2) and oo docs put-xlsx
to generate multi-sheet budgets with named inputs, SUM/AVG/MIN formulas,
cross-sheet links, Russian labels, and cell comments for OnlyOffice.

Template cutover-portugal: sheets Ввод, Вс 6.09, Чт 3.09, Ср гип, Сводка.
Upload via UploadProjectFileReplacing (--replace default).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 13:38:27 +01:00
eSlider 8739d45b1a Merge pull request 'chore(release): v0.15.0 — files folder-ops + docpipe (#8)' (#9) from chore/release-v0.15.0#8 into main
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Test (Go 1.25) (push) Successful in 23s
Tests / Secret scan (gitleaks) (push) Successful in 5s
Tests / Test (Go stable) (push) Successful in 26s
2026-08-29 16:19:41 +01:00
eSlider f3150436fd chore(release): rerun CI после gitleaks fix (#8)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 23s
Tests / Test (Go stable) (pull_request) Successful in 25s
2026-08-29 16:18:42 +01:00
eSlider a30ae20f32 Merge pull request 'fix(ci): gitleaks через бинарник (Gitea runner) (#10)' (#11) from fix/gitleaks-gitea#10 into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 6s
Tests / Test (Go 1.25) (push) Successful in 21s
Tests / Secret scan (gitleaks) (push) Successful in 4s
Tests / Test (Go stable) (push) Successful in 27s
Tests / Test (Go 1.25) (pull_request) Successful in 27s
Tests / Test (Go stable) (pull_request) Successful in 31s
2026-08-29 16:14:51 +01:00
eSlider 5a0b2ab412 fix(ci): gitleaks через бинарник на $GITHUB_WORKSPACE (Gitea runner) (#10)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 5s
Tests / Test (Go stable) (pull_request) Successful in 21s
Tests / Test (Go 1.25) (pull_request) Successful in 30s
2026-08-29 16:14:00 +01:00
eSlider 69844c9122 chore(release): v0.15.0 — files folder-ops + docpipe (#8)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Test (Go 1.25) (pull_request) Successful in 25s
Tests / Test (Go stable) (pull_request) Successful in 34s
Tests / Secret scan (gitleaks) (pull_request) Failing after 3s
2026-08-29 16:06:47 +01:00
eSliderandGitHub b04d610275 Merge pull request #42 from eSlider/feat/docs-optimize-gs
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go 1.25) (push) Successful in 20s
Tests / Test (Go stable) (push) Successful in 27s
feat(docs): Ghostscript PDF optimize for OO
2026-08-28 10:03:04 +01:00
eSliderandCursor 6b3f40e1a1 feat(docs): optimize PDF via Ghostscript pdfwrite
Add oo docs optimize for native-text PDFs (OO-friendly rewrite without
ocrmypdf overlay). Default ocr --force to false when text layer exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 10:02:58 +01:00
eSliderandGitHub ba8148a9e1 Merge pull request #41 from eSlider/fix/put-txt-fixed-width
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go 1.25) (push) Successful in 20s
Tests / Test (Go stable) (push) Successful in 27s
fix(docs): fixed-width txt as monospace code block
2026-08-28 09:59:59 +01:00
eSliderandCursor f1739dc9dc fix(docs): put-txt uses code block for fixed-width extracts
INE/pdftotext -layout txt (e.g. loan-ine) opens reliably in OO as
monospace; prose txt still uses hard line breaks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 09:59:34 +01:00
eSliderandGitHub 5b812ce346 Merge pull request #40 from eSlider/feat/put-txt-line-breaks
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go 1.25) (push) Successful in 26s
Tests / Test (Go stable) (push) Successful in 27s
feat(docs): put-txt with preserved line breaks
2026-08-28 09:58:04 +01:00
eSliderandCursor 309ae44ee4 feat(docs): put-txt preserves line breaks in DOCX
TxtToMarkdown uses markdown hard breaks so pandoc keeps each source
line on its own row. Adds oo docs put-txt and projects files alias.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 09:57:55 +01:00
eSliderandGitHub ff2bc539f5 Merge pull request #39 from eSlider/fix/delete-immediately
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 5s
Tests / Test (Go 1.25) (push) Successful in 28s
Tests / Test (Go stable) (push) Successful in 29s
fix(files): permanent delete via DeleteDavItems
2026-08-28 09:56:21 +01:00
eSliderandCursor d9a7adcd94 fix(files): DeleteDavItems with Immediately true
Soft-delete (Immediately false) failed on some project files with
"You don't have enough permission to create" when moving to Trash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 09:56:10 +01:00
eSliderandGitHub 3713ed6c51 Merge pull request #37 from eSlider/feat/files-dedupe
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 3s
Tests / Test (Go 1.25) (push) Successful in 26s
Tests / Test (Go stable) (push) Successful in 27s
feat(files): dedupe command + stem|ext matching
2026-08-27 23:40:16 +01:00
eSliderandCursor ac06d86363 feat(files): dedupe by stem|ext + oo projects files dedupe
- FileDedupKey groups true duplicates (same name+extension)
- put-md --replace deletes matching stem|ext only (not other formats)
- oo projects files dedupe PROJECT_ID [--apply] [--cross]
- Cross-folder mode prefers non-_trash folders, keeps newest file

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 23:40:07 +01:00
eSliderandGitHub 7d397ac680 Merge pull request #35 from eSlider/release-please--branches--main--components--go-onlyoffice
chore(main): release 0.14.0
2026-08-27 23:36:54 +01:00
github-actions[bot]andGitHub e52cb31bb7 chore(main): release 0.14.0 2026-08-27 22:36:44 +00:00
eSliderandGitHub a8cb4e9780 Merge pull request #36 from eSlider/fix/delete-files-dav
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go 1.25) (push) Successful in 1m29s
Tests / Test (Go stable) (push) Successful in 1m31s
fix(files): DeleteFiles actually removes files on produktor OO
2026-08-27 23:36:19 +01:00
eSliderandCursor 622dcdb7bf fix(files): DeleteFiles via per-file DELETE API
fileops/delete returns 200 on produktor.io OO without removing files;
route DeleteFiles through DeleteDavItems so put-md --replace and oo rm work.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 23:36:10 +01:00
eSliderandGitHub 2b267d36a8 Merge pull request #34 from eSlider/fix/put-md-upsert
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go stable) (push) Successful in 1m23s
Tests / Test (Go 1.25) (push) Successful in 1m31s
fix(docs): put-md upsert — no duplicate folder files
2026-08-27 23:29:09 +01:00
eSliderandCursor 50bd47570b fix(docs): put-md upsert by stem to avoid duplicate folder files
UploadToFolderReplacing deletes same-stem files before upload (--replace,
default true). OO may still display title+fileExst as double extension.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 23:28:11 +01:00
eSliderandGitHub 096a996726 Merge pull request #33 from eSlider/release-please--branches--main--components--go-onlyoffice
chore(main): release 0.13.0
2026-08-27 17:57:02 +01:00
github-actions[bot]andGitHub 625dd0a60a chore(main): release 0.13.0 2026-08-27 16:40:41 +00:00
eSliderandGitHub e531de280f Merge pull request #32 from eSlider/feat/docs-hocr
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 3s
Tests / Test (Go stable) (push) Successful in 1m43s
Tests / Test (Go 1.25) (push) Successful in 1m48s
feat(docs): hOCR → Markdown via go-hocr
2026-08-27 17:40:15 +01:00
eSliderandCursor 129e3a58cc chore: tidy go.sum after go-hocr bump
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:37:39 +01:00
eSliderandCursor 70e605b4ca docs(readme): document oo docs hocr
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:36:37 +01:00
eSliderandCursor a264cbd61c feat(docs): oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML
Structured OCR path for agents alongside ocrmypdf searchable PDF.
as-md --hocr selects the same pipeline for OO downloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 17:36:20 +01:00
eSliderandGitHub a97a160c44 Merge pull request #29 from eSlider/release-please--branches--main--components--go-onlyoffice
chore(main): release 0.12.0
2026-08-27 17:05:08 +01:00
github-actions[bot]andGitHub df447f2673 chore(main): release 0.12.0 2026-08-27 15:59:02 +00:00
eSliderandGitHub db9ef12ff4 Merge pull request #31 from eSlider/feat/docs-md-ocr
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Failing after 4s
Tests / Test (Go 1.25) (push) Successful in 1m57s
Tests / Test (Go stable) (push) Successful in 2m1s
feat(docs): md↔docx, OCR→PDF, as-md/put-md
2026-08-27 16:58:36 +01:00
eSliderandCursor 2c0df0d55f fix(ci): point gitleaks at /github/workspace in docker action
The docker:// runner mounts the checkout at /github/workspace; using
${{ github.workspace }} (host path) made gitleaks fail with ENOENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 16:56:40 +01:00
eSliderandCursor 239c5ea672 docs(readme): document oo docs md↔docx / OCR agent workflow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 16:49:18 +01:00
eSliderandCursor 6ea2fbadf7 feat(docs): md↔docx convert, OCR→PDF, as-md/put-md for agents
Add oo docs pipeline (pandoc/ocrmypdf) so agents keep Markdown locally while OnlyOffice stores versioned DOCX; OCR weak PDFs/images before returning MD. Also expose ListFolder/CreateFolder/MoveFiles/UploadToFolder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 16:49:06 +01:00
eSlider 50d8974154 Merge pull request 'feat(security): secret-scan в CI + pre-push хуки (#142)' (#5) from feat/secret-scan#142 into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 3s
Tests / Test (Go 1.25) (push) Successful in 44s
Tests / Test (Go stable) (push) Successful in 45s
2026-08-23 22:46:45 +01:00
eSlider 3fd3f797dc Merge pull request 'reconcile(github): sync GitHub-only commits into Gitea main (#144)' (#6) from reconcile/github-sync#144 into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Successful in 50s
Tests / Test (Go stable) (push) Successful in 44s
merge(github): sync GitHub-only commits into Gitea main (#144)
2026-08-23 19:55:48 +01:00
eSlider 0e299d0692 merge(github): bring GitHub-only commits into Gitea main (#144)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Test (Go 1.25) (pull_request) Successful in 48s
Tests / Test (Go stable) (pull_request) Successful in 50s
Merges GitHub main content (mail-send SendMail, files ops/WebDAV,
contact-indexes CRM, history whitelist) onto Gitea main so Gitea (source
of truth) contains every commit from both sides.
2026-08-23 19:55:39 +01:00
Andriy Oblivantsev 9ead554f5c feat(security): secret-scan via gitleaks in CI + pre-push/pre-commit hooks (#142)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 48s
Tests / Test (Go stable) (pull_request) Successful in 51s
2026-08-23 19:51:06 +01:00
eSlider 77c188c640 Merge origin/main (mail-send PRs, files ops) into local main
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Skipped
2026-08-21 22:03:04 +01:00
eSlider 2a59817f51 Merge feat/contact-indexes: crm indexes + history whitelist 2026-08-21 22:02:48 +01:00
eSlider 9c450a0352 feat(crm): contact email & person-opportunity indexes, history entity whitelist
- BuildContactEmailIndex: one-pass email→contact id map over all persons
  (O(N) instead of per-address FindPersonByEmail O(N×M))
- BuildPersonOpportunityIndex: person→lowest-id opportunity for history notes
  (OO has no person-level history; verified live)
- HistoryEntity{Opportunity,Case} whitelist constants
- IsCompany/ContactID/NumericIDLess/SortIDs helpers with unit tests
2026-08-21 22:02:47 +01:00
eSliderandGitHub ab7dfbd859 Merge pull request #28 from eSlider/feat/mail-send
feat(mail): oo mails send — SendMail client + guard empty-by-id
2026-08-21 17:21:18 +01:00
eSlider dda2bd3ca5 feat(mail): oo mails send — SendMail + guard empty-by-id send
- Client.SendMail: PUT /api/2.0/mail/messages/send.json, omits empty cc/bcc
- oo mails send subcommand with --id/--to/--subject/--body/--cc/--bcc
- guard: sending by --id without --body errors (send.json does not copy
  subject/body from the draft; prevents empty emails)
- unit test: empty cc/bcc omitted, correct path
2026-08-21 16:43:00 +01:00
eSliderandGitHub ceea6d909e Merge pull request #27 from eSlider/feat/mails-download-attachment-cli
oo CLI: mail attachment downloads
2026-08-21 15:25:08 +01:00
eSlider ecf34ba51a fix(files): rewrite viewUrl host to API base on download
resolveAPIURL now rewrites absolute viewUrl hosts to the configured portal
base so downloads stay on the internal network and keep the Authorization
header (a cross-host HTTP->HTTPS redirect would otherwise strip it, failing
large-file reads).
2026-08-20 10:48:38 +01:00
eSlider ebbd5d5373 feat(files): add WebDAV-oriented Files operations
Add value-oriented Files module operations for filesystem backends:
ListDavFolder, ListDavSections (@root), CreateDavFolder, RenameDavFolder/
RenameDavFile, MoveDavItems, CopyDavItems, DeleteDavItems, UploadDavFile
(streaming), DownloadDavFile. Dependency-free (net/http only).
2026-08-20 10:37:07 +01:00
eSlider 20a09530cd Merge pull request 'Add oo CLI support for mail attachment downloads' (#3) from feat/mails-download-attachment-cli into main
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Reviewed-on: #3
2026-08-19 12:17:10 +01:00
eSlider 666be883dc Add oo CLI support for mail attachment downloads.
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Test (Go 1.25) (pull_request) Successful in 24s
Tests / Test (Go stable) (pull_request) Successful in 26s
This exposes the canonical attachment download path via oo mails so downstream tools like 2dph can migrate off local OnlyOffice mail adapters without taking a direct module dependency first.
2026-08-19 12:10:21 +01:00
eSlider 65cd3f5c74 Merge pull request #2 from feat/mail-download-for-2dph
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Successful in 41s
Tests / Test (Go stable) (push) Successful in 41s
Add mail attachment download support for 2dph integration.
2026-08-19 12:08:04 +01:00
eSlider 1ef0670624 Add mail attachment download support for 2dph integration.
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Test (Go 1.25) (pull_request) Successful in 50s
Tests / Test (Go stable) (pull_request) Successful in 51s
This gives go-onlyoffice a cookie-backed attachment download path so 2dph can reuse the canonical OnlyOffice client instead of keeping a local mail adapter.
2026-08-19 12:05:25 +01:00
eSliderandGitHub 10c1cfc6a7 ci: node16-safe actions + GitHub-only gates for Gitea mirror (#25)
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Successful in 1m28s
Tests / Test (Go stable) (push) Successful in 1m23s
The Gitea act_runner runs action JS in node:16-bullseye; setup-go@v5 crashes
with 'ReferenceError: ReadableStream is not defined'. Gitea discovers
.github/workflows too, so:

- setup-go@v5 -> @v4 (node16-safe, fine on GitHub)
- gate GoReleaser (release.yml) + release-please jobs to
  github.server_url == https://github.com so the GitHub CD never runs on the
  Gitea mirror (GoReleaser/release-please are GitHub-release-centric)
2026-08-19 11:12:58 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
8ced804a6f chore(main): release 0.11.0 (#24)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 47s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-19 08:52:13 +01:00
eSlider 256864f613 feat(catalog): port oo catalog (scan/merge/match/apply) from legacy branch
Release / GoReleaser (push) Skipped
Release Please / Release Please (push) Failing after 2s
Tests / Test (Go 1.25) (push) Successful in 1m22s
Tests / Test (Go stable) (push) Successful in 1m23s
The catalog feature (approved ASR-0014) was lost during the GitHub history
rewrite: the merge commit on main is empty and the code only survived on the
old gitea-history branches (no merge base with main). Port catalog/* and the
oo catalog CLI verbatim; drop the library-only applications package whose
Sync() created the 'Write application'/'Apply application'/'Send CV' CRM
task spam (23k+ tasks deleted 2026-08-18).
2026-08-18 12:41:58 +01:00
eSliderandGitHub a3a6ad3543 Merge pull request #23 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 46s
chore(main): release 0.10.0
2026-08-13 13:13:52 +01:00
github-actions[bot]andGitHub 0161a92c52 chore(main): release 0.10.0 2026-08-13 09:21:46 +00:00
eSlider 0e80a1a058 Add milestone create and task start dates so Gantt bars can exist.
CLI: oo projects milestone-create, oo tasks create --start --milestone.
2026-08-13 10:21:15 +01:00
eSlider 8b27539145 chore: restore LICENSE copyright holder
History rewrite anonymized author strings; keep accurate MIT copyright on tip.
2026-08-10 00:14:08 +01:00
eSlider a0add3b771 chore: anonymize remaining CLI examples and hostnames
Keep public docs/help free of client names and fixed portal URLs.
2026-08-10 00:13:52 +01:00
eSlider b58940fcf2 chore: redact personal ops from public tree
Move catalog, dossier sync, and SearXNG to private oo-workspace; keep
generic CRM name/title helpers and anonymized docs/examples.
2026-08-10 00:12:54 +01:00
eSlider 11aa23f1a1 docs(crm): Team vs Contacts and persons update note 2026-08-09 14:28:17 +01:00
eSlider 42c31d4b48 fix(crm): JSON person update and oo persons update CLI
Form-encoded PUT ignores companyId/about and can unlink the employer;
use JSON and re-fetch. Expose update plus contact-tag helpers on oo.
2026-08-09 14:28:09 +01:00
eSliderandGitHub f95830961d Merge pull request #20 from eSlider/feat/crm-invoices
feat(crm): invoices CLI and opportunity update
2026-08-06 14:58:30 +01:00
eSlider 6b5a82bef8 docs(crm): clarify mail API send vs signature for chat links 2026-08-06 14:52:35 +01:00
eSlider 490426c277 docs(crm): capture association graph and invoice/mail quirks
Document Medex-learned OO rules (one company, entityId at create, PDF
cache/cleanup, Matrix # truncation) and add invoices pdf/status helpers.
2026-08-06 14:26:43 +01:00
eSlider 31c556d34c feat(crm): allow updating invoice terms via oo
Needed to put issuer NIE in Bedingungen without a full recreate.
2026-08-06 13:35:08 +01:00
eSlider 7f35b0e910 fix(mail): German spacing for invoice draft template
Separate paragraphs with blank lines; mailbox signature uses Synapse MXID.
2026-08-06 13:25:53 +01:00
eSlider 73b1050a83 feat(mail): draft, attach, and draft-invoice CLI
Save OO Mail drafts via body field, attach Files documents, and one-shot
invoice PDF regenerate + draft for client billing emails.
2026-08-06 13:18:02 +01:00
eSlider d81e7de911 feat(crm): invoice update notes and PO fields
Allow oo invoices update --description/--po so Notizen can be
multiline refs without portal URLs; keep opportunity link support.
2026-08-06 12:47:06 +01:00
eSlider 3201a58df7 fix(crm): link invoices to opportunities
Create/update accept entityId (opportunity); oo invoices create
--opportunity and invoices update --opportunity so deal #invoices
tabs show the draft.
2026-08-06 12:45:09 +01:00
eSlider 19a7e9cbf8 feat(crm): add invoices CLI and opportunity update
Expose CRM invoice list/get/create/delete and catalog items via oo,
plus contact address/about helpers and opportunity bid/stage update.
Needed for Medex VM billing and other draft invoices.
2026-08-06 12:32:20 +01:00
eSliderandGitHub 2e1624920e Merge pull request #16 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 14s
chore(main): release 0.9.0
2026-07-26 14:27:42 +01:00
github-actions[bot]andGitHub 1bf417d70f chore(main): release 0.9.0 2026-07-26 12:27:06 +00:00
eSlider aa79a83c0a feat(projects): link CRM companies and git authors to projects
Add List/Add/Remove project contacts and CLI:
contacts list|add|remove|link-git|link-authors.
Contacts tab (CRM) is separate from ProjectTeam portal users.
2026-07-26 13:25:50 +01:00
eSlider 179f30644d fix(crm): clean person names; canonical project titles
Strip company annotations from lastName, derive names from email locals,
UpdatePerson on apply with companyId. Add persons fix-names, contacts
merge, projects create --country/--company (CC | Company | Title).
Fix project update (responsibleId) and company-person dedupe grouping.
2026-07-26 13:11:54 +01:00
eSliderandGitHub b334f9ff86 Merge pull request #15 from eSlider/feat/oo-catalog-clients-contacts
feat(oo): catalog scan/match/apply for CRM clients and contacts
2026-07-26 12:54:41 +01:00
eSlider e07cf743f9 feat(catalog): mbox header scan, company legal-suffix match, apply order
Add --mbox-headers for Thunderbird folder files; strip GmbH/& Co. KG in
CompanyGroupingKey; FindCompany falls back to full scan; apply companies
before persons. Tightens role-mailbox noise filters.
2026-07-26 11:34:42 +01:00
eSlider 8b764a36a1 feat(catalog): scan Thunderbird address books and Gloda contacts
Add oo catalog scan-thunderbird for *.mab and global-messages-db.sqlite,
with noise filters and work-domain heuristics (e.g. wheregroup.com).
2026-07-26 11:27:57 +01:00
eSlider aa4e71b9d2 feat(oo): catalog scan/match/apply for CRM clients and contacts
Add filesystem inventory (VCF, contact folders, project trees) into a
reviewable YAML catalog, match against live OnlyOffice, and upsert only
rows with approve:true. Includes FindPersonByEmail for email-keyed persons.
2026-07-26 11:10:02 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1a93579635 chore(main): release 0.8.3 (#14)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 15:41:46 +01:00
eSliderandGitHub 76067f65d0 fix(ci): dispatch GoReleaser after release-please; allow Tests dispatch (#13)
GITHUB_TOKEN cannot trigger push:tags workflows, so Release never ran
after release-please. Dispatch release.yml explicitly when a release is
created. Add workflow_dispatch to Tests for on-demand main verification.
2026-07-23 15:38:57 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3b55de5b1d chore(main): release 0.8.2 (#12)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 15:34:27 +01:00
eSliderandGitHub 715ed23733 fix(ci): retry tag fetch in Release workflow (#11)
workflow_dispatch raced release-please: checkout of refs/tags/vX failed
because the tag was not fetched yet. Clone main first, retry-fetch the
tag, then check it out.
2026-07-23 15:31:56 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c7275b5c98 chore(main): release 0.8.1 (#10)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 15:11:58 +01:00
eSliderandGitHub b9cea62d46 fix(oo): assign owner and deadline on task create (#9)
* fix(oo): assign owner and deadline on task create

CRM and project task create omitted responsibleId/responsibles, so the
portal attached a removed stub profile and CRM create without a deadline
failed or looked empty in the UI. Default both to the authenticated user
and now+14d; add crm-tasks reassign-self for backfill.

* fix(oo): collect all bad CRM tasks before reassign-self apply

Offset-0 apply stopped early when the first page was already fixed.
2026-07-23 15:01:23 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e3c706aefe chore(main): release 0.8.0 (#8)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 14:48:32 +01:00
eSliderandGitHub 6ea76c64c3 Merge pull request #5 from eSlider/release-please--branches--main--components--go-onlyoffice
chore(main): release 0.7.0
2026-07-23 14:46:30 +01:00
github-actions[bot]andGitHub 8804bd3030 chore(main): release 0.7.0 2026-07-23 13:46:17 +00:00
eSliderandGitHub c8b30a36ee Merge pull request #7 from eSlider/fix/oo-applications-discover
fix(oo): skip junk dirs in applications Discover
2026-07-23 14:45:57 +01:00
eSlider 3969d045cc fix(oo): skip junk dirs in applications Discover
Stop inventing CRM companies from node_modules/tools README trees.
Only sync dossier-like folders (source-NNN-…) and invent Company from
the slug token after the numeric id.
2026-07-23 14:43:45 +01:00
eSliderandCursor 09e242d7c3 refactor(office): generalize DataTable layout and document TUI table skill
Unify project/user flex column layout into layoutFlexTable with per-subject
policy in model. Add office-tui-table skill and AGENTS notes; fix CI go mod
tidy drift and test matrix Go version to match go.mod 1.25.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:21:20 +01:00
eSliderandCursor 0ed2d9b0f5 feat(office): users admin UI, table rendering, and user save fixes
Add Users list/detail with ACL toggles and library people update/status APIs.
Fix project table layout and ANSI-safe cell rendering, and decode array
responses from people/status so saves no longer fail on JSON unmarshal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:07:22 +01:00
eSliderandCursor fab2977cb1 feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Polish the office TUI with proportional pane layout, drag resize, live
filtering, HTML mail preview, paginated mail lists, overflow scrollbars,
flattened navigation, and project save/status fixes; add UpdateProjectStatus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:23:25 +01:00
eSliderandGitHub eed57a68d4 Merge pull request #4 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
chore(main): release 0.6.0
2026-06-24 21:26:47 +01:00
github-actions[bot]andGitHub b2aae21390 chore(main): release 0.6.0 2026-06-24 16:36:03 +00:00
eSliderandCursor 3d4aa10cf9 docs: CHANGELOG for office v0.5.1 release
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:35:44 +01:00
eSliderandCursor 3b47d1400b feat(office): table detail panes and Alt+1/2/3 layout toggles
Add spreadsheet-style list, split detail pane with forms and document preview,
and Alt+1/2/3 to show/hide panes with even width distribution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:33:50 +01:00
eSliderandGitHub a413d409b5 Merge pull request #3 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
chore(main): release 0.5.0
2026-06-24 16:53:54 +01:00
github-actions[bot]andGitHub cac0ffa2a0 chore(main): release 0.5.0 2026-06-24 15:43:20 +00:00
eSliderandCursor cad68f64c6 feat(office): scrollable panes, nav tree drill-down, and item actions
Add viewports to all three columns with cursor sync scrolling. Support
Shift+Tab reverse pane focus. Replace flat menu with hierarchical NavTree
(list loads only on leaf nodes; dynamic project subnodes with Tasks/Files).
Enter or a opens per-item action menu (view, delete, download) wired to
the OnlyOffice API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:43:06 +01:00
eSliderandCursor 4046f4c46a test(office): replace fixture tests with live OnlyOffice API integration
Remove synthetic map-based entity and fetch mapper unit tests; cover all
menu subjects and preview pipelines via integration tests against the real
Workspace API. Handle draft mail without subjects in list mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:37:46 +01:00
eSliderandCursor 6d4df4edee ci: allow manual GoReleaser dispatch for existing tags
Release Please creates GitHub releases without pushing tag events that
reliably trigger GoReleaser; workflow_dispatch publishes binaries for a
given tag on demand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:33:12 +01:00
eSliderandGitHub 3931eeaabb chore(main): release 0.4.0
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
chore(main): release 0.4.0
2026-06-24 16:31:41 +01:00
github-actions[bot]andGitHub 2959168d76 chore(main): release 0.4.0 2026-06-24 15:29:27 +00:00
eSliderandCursor f8a73edef3 feat(office): add Workspace TUI with shared bootstrap and test suite
Introduce the office Bubble Tea browser (module tree, multi-select lists,
markdown preview) alongside oo, with cmd/internal/bootstrap for shared auth,
unit tests across preview/model/ui/fetch, CI build for both binaries, and
GoReleaser archives for office.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:29:04 +01:00
eSliderandCursor 4acec6bbc9 ci: fix release-please permissions and document release workflow
Enable workflow_dispatch, add issues:write, and document the repo Actions
setting required for release-please PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:50:04 +01:00
eSliderandCursor 2e77d8e5c7 ci: add GitHub Actions test pipeline and GoReleaser releases for oo
Mirror eSlider/go-config: release-please for semver, tag-triggered GoReleaser
builds cross-platform oo binaries with embedded version metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:46:53 +01:00
eSliderandCursor 87ce3257a8 feat(mails): add Workspace mail CLI with pagination and parsed from fields
Manage the OnlyOffice Mail addon via oo mails using existing ONLYOFFICE_* creds,
with automatic page fetching past the 25-message API cap and fromName/fromAddress columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:44:09 +01:00
eSliderandCursor a59b4fb296 feat(crm): merge company slogan variants in dedupe grouping
Treat names like "Affirm" and "Affirm — Fraud Engineering" as the same
company for dedupe, deal matching, members, and applications sync lookup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 12:40:27 +01:00
eSliderandCursor b79c097874 feat(crm): dedupe duplicates, fix deal titles, and add cleanup CLI
Merge duplicate companies/persons/deals by name, remove duplicate contact
info and opportunity members, and rename malformed " @ Company" titles.
Applications sync now avoids empty-position titles and duplicate members.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 12:34:14 +01:00
eSlider 1b238aca5a feat(files): project/task Documents API + oo projects|tasks files
Library (files.go):
- Types: FileEntry, FolderEntry, ProjectFilesResponse.
- GetProjectFiles, GetTaskFiles, GetFile, UploadProjectFile (POST .../upload),
  AttachFilesToTask (POST form files=), UploadTaskFile (upload+attach),
  DetachTaskFile (DELETE ?fileid=), RenameFile, DeleteFiles, DownloadFile.
- Helpers: FileEntryNumericID, FileEntryTitle, SafeLocalFileName, projectIDFromTaskMap.

http.go: putJSON for JSON PUT bodies.

CLI: oo projects files {list,upload,download,rename,delete}, oo tasks files
{list,upload,detach}; projects_files.go + tasks_files.go.

Tests: files_integration_test.go (live), files_test.go + testdata (decode).

Docs: CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree.
Made-with: Cursor
2026-04-24 16:28:39 +01:00
eSlider 4eb14c082c feat(cli)!: subject-based command tree (tea-style) + global --output flag
BREAKING:
- Flat oo verb-noun layout replaced with subject → verb, mirroring the
  gitea `tea` CLI. Every subject is one file in cmd/oo/:

    oo calendar       list | events | add | delete
    oo projects       list | get | milestones | create | update | delete
    oo tasks          list | get | create | update | delete | subtask add
    oo users          list | self                  (alias: oo whoami)
    oo contacts       list | get | delete | info-add
    oo persons        list | create | delete
    oo companies      list | create | delete
    oo opportunities  list | get | create | delete | stages | member-add
    oo cases          list | create | delete | member-add
    oo crm-tasks      list | create | delete | categories
    oo applications   sync

- Global `--output/-o table|json` flag on the root command; every list uses
  a single printTable helper that flattens nested bidCurrency → abbreviation
  for readability, truncates long cells, and dumps verbatim in JSON mode.

Library additions surfaced in the CLI:
- oo users / oo whoami via SelfUserID + GetUsers.
- oo projects full CRUD via Create/Update/Delete/GetProjectByID/Milestones.
- oo crm-tasks and oo cases via ListCRMTasks/CreateCRMTask/DeleteCRMTask/
  ListTaskCategories/ListCases/CreateCase/DeleteCase/AddCaseMember.
- oo contacts info-add via AddContactInfo.
- oo opportunities stages/member-add via ListDealStages/AddOpportunityMember.

Internal:
- cmd/oo/common.go — rootCmd, newOO (uses AuthenticateContext), printTable,
  printObject, fmtCell, deref* helpers.
- cmd/oo/crm.go deleted; content split into contacts.go, opportunities.go,
  cases.go, crm_tasks.go for locality.

Docs:
- README: new subject→verb table + migration note.
- AGENTS.md: CLI section rewritten; new rule enforcing subject→verb and
  printTable/printObject routing.
- CHANGELOG: 0.5.0 entry with full old→new mapping.

Made-with: Cursor
2026-04-24 14:55:43 +01:00
eSlider 0973975471 refactor!: rename oo-cli → oo, split library by domain, relocate applications
BREAKING:
- Binary renamed oo-cli → oo; install path is now
  github.com/eslider/go-onlyoffice/cmd/oo.
- Former internal/cli tree removed; cobra commands live in cmd/oo/ as
  package main, split by domain (calendar.go, crm.go, tasks.go, apps.go,
  common.go, main.go).
- internal/applications moved to cmd/oo/applications/ (CV-specific workflow;
  not a library feature).
- examples/applications removed (it depended on an internal package).

Library split (mechanical, zero API surface change):
- client.go  — Client, Credentials, Defaults, env helpers, NewClient.
- request.go — Request, Query, Time, Token, MetaResponse, Permissions.
- auth.go    — Authenticate/AuthenticateContext/InvalidateToken.
- http.go    — transport + ResponseArray/ResponseObject/postFormObject/
               putFormObject/deleteObject/unmarshalResponseObject (renamed
               from httpx.go).
- projects.go, tasks.go, users.go, calendar.go, crm.go, files.go — typed
  / untyped domain methods. tasks_extra.go merged into tasks.go.
- onlyoffice.go deleted (content redistributed).

AGENTS.md, CHANGELOG.md, README.md updated accordingly.

Made-with: Cursor
2026-04-24 14:55:21 +01:00
eSlider e2d481e7b8 refactor: DRY auth path, fix Sprintf/RE2 bugs; real integration tests only
Why
---
- Sprintf(*p.Title) fed the title as a format string — % in titles broke
  the String() method; also panicked on nil Title.
- internal/applications.buildSummary used RE2-unsupported `(?= ...)`
  lookahead inside regexp.MustCompile, panicking the first time the
  applications-sync path was exercised on Go 1.23+.
- Query() duplicated the auth-expiry check inline while ensureToken()
  already handled it — two code paths drifted.
- Request.Debug split Query() into two branches that both unmarshalled
  into the same target value. Dead code.
- httptest fixtures that emulated OnlyOffice endpoints were lying to us:
  they passed locally yet never caught a single real protocol regression.

What
----
- Project.String(): nil-safe, no Sprintf format-string interpretation.
- buildSummary regex: RE2-safe non-capturing trailing delimiter
  `(?:\n## |$)` replaces the lookahead.
- Query() routes through ensureToken(); body marshalling factored into
  an unexported requestBodyReader(). Debug flag retained for backwards
  compatibility, documented as a no-op, to be removed at next major.
- Dropped Debug: true stray flags in GetTasks/UpdateProjectTask.
- Deleted httptest-based OnlyOffice mocks. unit_test.go is now pure Go
  (parsers, helpers, env aliases, ctx cancellation against an unroutable
  address). client_test.go is `//go:build integration` and runs against
  a real OnlyOffice, skipping cleanly without ONLYOFFICE_URL/USER/PASS.
- AGENTS.md + .cursor/rules/no-synthetic-mocks.mdc document the new
  testing policy.

Verified
--------
- `go test ./...` green (15 unit tests across package + internal).
- `go test -tags=integration ./...` green against live
  office.produktor.io (5 integration tests: auth, projects, lifecycle,
  calendar+CRM read, task list).
- inventar-sync smoke dry-run against live OO project 33 + Gitea found
  30 tasks, 0 mutations.

Made-with: Cursor
2026-04-24 12:36:40 +01:00
eSlider 2fc2adadd1 feat(auth): AuthenticateContext + InvalidateToken for long-running syncs
Adds two helpers tailored for cron-driven or watcher-style clients:

- AuthenticateContext(ctx) — cancellable variant of Authenticate(). Bypasses
  the non-context Query() path and POSTs /api/2.0/authentication.json directly,
  so a stalled auth call never outlives the caller's deadline.
- InvalidateToken() — zeroes the cached *Token. Next request (or Authenticate*)
  forces a fresh auth. Intended for mid-sync 401 recovery when the server has
  revoked/rotated the session while local Expires still looks fresh.

Plain Authenticate() is unchanged; it remains a convenience wrapper.

Unit tests cover cache-hit, forced refresh, and context-cancellation paths.

Refs eSlider/inventar-sync#3, ASR-0008.

Made-with: Cursor
2026-04-24 12:22:26 +01:00
eSlider b63055d435 docs: add library examples for calendar, crm, subtasks, applications
Four new executable examples mirror the oo-cli subcommands at library level so
downstream Go consumers can see the typed API without reading the CLI wiring.
README table updated to list all examples.

Made-with: Cursor
2026-04-24 11:46:09 +01:00
eSlider 842e9aa132 feat: merge oo-cli and extend library with Calendar/CRM/subtasks/files
- Add Calendar helpers (ListCalendars/ListEvents/AddEvent/DeleteEvent)
- Add CRM helpers: contacts, opportunities, cases, CRM tasks, history notes
- Add project-task extras (ListTasks/ListAllTasks/GetTaskByID/AddTask/
  AddSubtask/UpdateTaskStatus/DeleteTask, GetProjectByID)
- Add UploadOpportunityFile (multipart) and SelfUserID caching
- Introduce Defaults struct, SetDefaults, GetEnvironmentDefaults;
  GetEnvironmentCredentials now accepts ONLYOFFICE_HOST/NAME/PASSWORD aliases
- Add public Authenticate() helper that eagerly primes the token
- Bundle Cobra CLI at cmd/oo-cli with full command surface
  (calendar, tasks, subtasks, CRM, applications-sync); CLI-only deps
  (cobra, godotenv) kept out of the library surface
- Add httptest-based unit tests for form/multipart helpers
- Docs: CHANGELOG, AGENTS, .env.example, README updates for v0.3 features

Consolidates the standalone eSlider/oo-cli tool into this canonical Go
module (see eSlider/inventar ASR-0008).

Made-with: Cursor
2026-04-24 11:30:11 +01:00
eSlider 0e70467522 Add badges: pkg.go.dev, license, Go version, release, stars 2026-02-13 14:02:49 +01:00
187 changed files with 27579 additions and 755 deletions
+38
View File
@@ -0,0 +1,38 @@
---
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
globs:
- "**/*_test.go"
alwaysApply: false
---
# Testing policy — no synthetic vendor mockups
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
other third-party API. Simulated vendor responses drift from reality, give
false green signals, and hide protocol changes.
## What to do instead
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
struct conversions, pure helpers. No `httptest` that fakes the vendor.
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
file. Read credentials from env:
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
regular `go test ./...` stays green in CI.
3. **Run integration**: `go test -tags=integration ./...`.
4. **Every new endpoint** ships with an integration test in the same PR.
## Narrow exception
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
OK when the test server is pretending to be OnlyOffice or Gitea.
## Migrating existing tests
If you find a test that handles routes like `/api/2.0/...` and returns canned
JSON, convert it to an integration test (or delete it if the behaviour is
already covered by integration).
+75
View File
@@ -0,0 +1,75 @@
---
name: office-tui-table
description: >-
Build and fix Bubble Tea list tables in cmd/office (DataTable, column layout,
lipgloss cell rendering, pane width). Use when changing office center-pane
tables, column widths, ANSI styling, or list toolbar alignment.
---
# office TUI table (`cmd/office/ui`)
## Architecture
| Piece | File | Role |
|-------|------|------|
| `DataTable` | `table.go` | cursor, sort, viewport, header/row render |
| Column layout | `table_layout.go` | flex vs horizontal-scroll policies |
| Flex policy | `model/table_layout.go` | per-subject flex column key + min width |
| Cell render | `table_render.go` | bubbles/table pattern + ANSI-safe padding |
| Subject row style | `table_projects.go` | optional per-subject cell styles |
| Pane sizing | `scroll.go` | `paneContentWidth`, `paneLipglossWidth` |
| Scrollbar pad | `scrollbar.go` | `padANSIWidth` for styled lines |
## Layout policies
1. **Flex (all columns visible)** — register in `model.TableFlexLayoutFor`:
- fixed columns keep `TableColumn.Width`
- one `FlexColumnKey` (e.g. `title`, `email`) absorbs remainder
- `layoutFlexTable` shrinks fixed cols down to `minFixedColumnWidth` before flex min
2. **Scrolling (default)** — `layoutScrollingTable`:
- horizontal `colScroll`; subset of columns visible
- `distributeColumnWidths` fills pane width
Add a new flex subject: one entry in `TableFlexLayoutFor` only — do **not** copy `layoutProjectTable`/`layoutUserTable` per subject.
## Cell rendering (required pattern)
Never `style.Width(w).Padding(0,1).Render(text)` on styled cells — padding doubles display width and breaks `JoinHorizontal`.
Use `renderTableCell` from `table_render.go` (matches `charmbracelet/bubbles/table`):
```go
renderTableCell(outerStyle, plainText, columnWidth)
```
- truncate **plain** text with `runewidth.Truncate` inside `renderTableCell`
- inner box: `Width(innerW).Inline(true)` where `innerW = width - outer.GetHorizontalFrameSize()`
- outer style carries padding, background, foreground
## ANSI rules
- **Never** `runewidth.Truncate` / `padDisplayWidth` on strings that already contain lipgloss ANSI codes — corrupts escapes and shows garbage like lone `ID`.
- Use `padANSIWidth` (`charmbracelet/x/ansi`) for styled lines (headers, toolbar, scrollbar rows).
- Do not wrap an already full-width styled header in another `lipgloss.Width()` — pad once.
## Pane width
Lipgloss bordered panes render **2 cells wider** than `Style.Width`:
- `paneLipglossWidth(rendered) = rendered - 2` for `.Width()` on pane style
- `paneContentWidth(rendered) = rendered - 4` for table/viewport inner size (border + padding)
Pass `paneContentWidth(pw.List)` to `DataTable.SetSize` and list toolbar.
## Tests to add when touching tables
- `layoutFlexTable` width sum equals pane width
- `TestProjectTableHeaderFitsPaneWidth` / per-line width at 68, 40, 30
- `TestPadANSIWidthPreservesStyledLine`
- `TestThreePaneRenderedWidthMatchesTerminal` top border + table lines ≤ inner width
## Anti-patterns
- Per-subject duplicate layout files (`table_foo.go` with copy-pasted shrink loops)
- Skipping truncation on selected rows (causes multi-line rows in viewport)
- Using `bubbles/table` widget directly — office needs column cursor, multi-select, subject-specific row styles
+27
View File
@@ -0,0 +1,27 @@
# OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
# does not load .env files — only its CLI does, via godotenv).
#
# Primary names:
ONLYOFFICE_URL=https://office.example.com
ONLYOFFICE_USER=
ONLYOFFICE_PASS=
# Accepted aliases (only one of each pair is needed):
# ONLYOFFICE_HOST=
# ONLYOFFICE_NAME=
# ONLYOFFICE_PASSWORD=
#
# Optional CLI-only aliases:
# OO_URL=
# OO_USER=
# OO_PASS=
# Optional defaults for calendar/project helpers:
ONLYOFFICE_CALENDAR_ID=1
ONLYOFFICE_PROJECT_ID=33
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
# ONLYOFFICE_DOCS_URL=https://docs.example.com
# ONLYOFFICE_DOCS_SECRET=
+42
View File
@@ -0,0 +1,42 @@
name: Release Please
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
issues: write
actions: write
jobs:
release-please:
name: Release Please
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- name: Run release-please
uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
# Tags/releases created with GITHUB_TOKEN do not trigger other workflows
# (push:tags on Release.yml never fires). Dispatch GoReleaser explicitly.
- name: Trigger GoReleaser
if: ${{ steps.release.outputs.release_created == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag_name }}
run: |
set -euo pipefail
echo "Dispatching Release workflow for $TAG"
gh workflow run release.yml --repo "${{ github.repository }}" -f "tag=${TAG}"
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
+58
View File
@@ -0,0 +1,58 @@
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Git tag to release (e.g. v0.4.0)
required: true
type: string
permissions:
contents: write
jobs:
goreleaser:
name: GoReleaser
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
# Always clone default branch first. workflow_dispatch often races with
# release-please creating the tag; fetching refs/tags/X before it exists
# fails checkout. Retry fetch, then check out the tag.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout release tag
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
run: |
set -euo pipefail
echo "Releasing tag: $TAG"
for i in 1 2 3 4 5 6; do
if git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"; then
break
fi
echo "tag $TAG not visible yet (attempt $i); sleeping…"
sleep 10
done
git rev-parse "refs/tags/${TAG}"
git checkout --force "refs/tags/${TAG}"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+95
View File
@@ -0,0 +1,95 @@
name: Tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
secret-scan:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute scan range (diff of new commits only)
id: range
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}"
else
BEFORE="${{ github.event.before }}"
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
RANGE="$(git rev-list --max-parents=0 HEAD | tail -1)..$GITHUB_SHA"
else
RANGE="$BEFORE..$GITHUB_SHA"
fi
fi
echo "RANGE=$RANGE" >> "$GITHUB_ENV"
echo "Scanning range: $RANGE"
# Install the gitleaks binary instead of a docker action: the
# docker://zricethezav/gitleaks action hardcodes /github/workspace,
# which does not exist on the Gitea (act) runner. $GITHUB_WORKSPACE is
# the checkout dir on BOTH runners (GitHub and Gitea act). Mirrors the
# fix applied to 2dph (issue #142).
- name: Gitleaks (diff-only, fail on leak)
env:
GITLEAKS_RANGE: ${{ env.RANGE }}
run: |
set -euo pipefail
curl -fsSLo /tmp/gitleaks.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
chmod +x /tmp/gitleaks
/tmp/gitleaks detect \
--source "$GITHUB_WORKSPACE" \
--log-opts="$GITLEAKS_RANGE" \
--redact \
--verbose
test:
name: Test (Go ${{ matrix.go }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go: ["1.25", "stable"]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
cache: true
- name: Verify dependencies
run: |
go mod verify
go mod download
- name: Check go mod tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum
- name: Vet
run: go vet ./...
- name: Build oo
run: go build -trimpath -buildvcs=false -o /tmp/oo ./cmd/oo
- name: Build office
run: go build -trimpath -buildvcs=false -o /tmp/office ./cmd/office
- name: Run tests
run: go test -race -shuffle=on -count=1 ./...
+11
View File
@@ -0,0 +1,11 @@
# Local environment
.env
# Go workspace (for local multi-module dev; see inventar ASR-0008)
go.work
go.work.sum
# Build artifacts (top-level compiled binaries; cmd/oo/ source tree is tracked)
/oo
/oo-cli
/bin/
+124
View File
@@ -0,0 +1,124 @@
version: 2
project_name: go-onlyoffice
before:
hooks:
- go mod tidy
- go mod verify
builds:
- id: oo
main: ./cmd/oo
binary: oo
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
- id: office
main: ./cmd/office
binary: office
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
archives:
- id: oo
ids: [oo]
name_template: "oo_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
- id: office
ids: [office]
name_template: "office_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
checksum:
name_template: checksums.txt
algorithm: sha256
snapshot:
version_template: "{{ .Tag }}-next"
changelog:
use: github
sort: asc
groups:
- title: Features
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 0
- title: Bug fixes
regexp: '^.*?fix(\(.+\))??!?:.+$'
order: 1
- title: Performance
regexp: '^.*?perf(\(.+\))??!?:.+$'
order: 2
- title: Others
order: 999
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^style:"
- "^build:"
- "Merge pull request"
- "Merge branch"
release:
draft: false
prerelease: auto
mode: replace
header: |
## Release {{ .Tag }}
Install:
```sh
go install github.com/eslider/go-onlyoffice/cmd/oo@{{ .Tag }}
go install github.com/eslider/go-onlyoffice/cmd/office@{{ .Tag }}
```
Or download prebuilt binaries below.
footer: |
**Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.16.0"
}
+59
View File
@@ -0,0 +1,59 @@
# AGENTS — go-onlyoffice
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo` command.
## Topology
- **Library — flat package `onlyoffice` at repo root.** Split by *domain file*, not by subpackage, so every call site reads `c.XxxYyy()` against a single `*Client`. Files:
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go`, `invoices.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents**. **`mails.go`** — OnlyOffice Workspace Mail. **`invoices.go`** — CRM invoices, PDF regen/cleanup, status. Association rules: [`docs/crm-associations.md`](docs/crm-associations.md).
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
- `main.go` — entry point (docstring lists the command tree).
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`.
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
- **Personal ops tooling** (disk inventory, dossier→CRM sync, SearXNG) lives in private [`eSlider/oo-workspace`](https://git.produktor.io/eSlider/oo-workspace) (`oow`), not in this public tree.
## Rules
- Library must never call `godotenv.Load()` — the CLI does that.
- New endpoints go into the library first; CLI commands are thin wrappers.
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`).
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
### Testing policy (2026-04-24)
**No synthetic OnlyOffice mockups.** Protocol-level behaviour must be
verified against a real OnlyOffice instance. `httptest.NewServer` is only
acceptable for testing the *caller's* logic that the library can't reach
(for example, the user's own HTTP handler). Anywhere we would otherwise
write `mux.HandleFunc("/api/2.0/...")` to emulate OnlyOffice, we write an
**integration test** instead.
- Unit tests (`*_test.go`, no build tag) — pure Go: parsers, encoders,
struct conversions. No network. No fake servers that emulate the vendor.
- Integration tests (`//go:build integration` tag in `*_integration_test.go`)
— hit a live OnlyOffice instance. Credentials come from `ONLYOFFICE_URL`,
`ONLYOFFICE_USER`, `ONLYOFFICE_PASS` (aliases `_HOST`/`_NAME`/`_PASSWORD`
also accepted). Tests **skip** cleanly when credentials are missing so
`go test ./...` remains green in CI.
- Run integration with: `go test -tags=integration ./...`.
- New endpoints **must** ship with an integration test before merge.
## Related
- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions).
- [`eSlider/inventar-sync`](https://git.produktor.io/eSlider/inventar-sync) — OnlyOffice → Gitea issue sync, consumes this library.
- [`produktor.io/vidarr`](https://git.produktor.io/produktor.io/vidarr) — legacy consumer being migrated from `pkg/onlyoffice` to this module.
+562
View File
@@ -0,0 +1,562 @@
# Changelog
All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
### Features
* **docs:** `put-xlsx` — multi-sheet бюджеты с named inputs, SUM/AVG/MIN
формулами, cross-sheet ссылками и cell comments (`internal/xlspipe`,
excelize) ([68445b0](https://github.com/eSlider/go-onlyoffice/commit/68445b0))
### Added
* **docs:** CRM association graph and OO quirks (`docs/crm-associations.md`)
* **crm:** `ForceRegenerateInvoicePDF`, `SetInvoiceStatus`, `PurgeStaleInvoicePDFs`, contact/opportunity file list helpers
* **oo:** `invoices pdf`, `pdf-cleanup`, `status`; create `--consignee`; draft-invoice force-regens PDF
### Fixed
* Document that invoice→deal must be set at create (`update --opportunity` often HTTP 400)
## [0.15.0](https://github.com/eSlider/go-onlyoffice/compare/v0.14.0...v0.15.0) (2026-08-29)
### Features
* **files:** `ListFolder`, `CreateFolder`, `MoveFiles`, `UploadToFolder` — Documents folder helpers for OO Documents ingestion ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fba))
* **files:** dedupe by stem|ext (`files_stem.go`) + `oo projects files dedupe` ([ac06d86](https://github.com/eSlider/go-onlyoffice/commit/ac06d86))
* **docs:** `internal/docpipe` — md↔docx convert, OCR→PDF, hOCR→Markdown via go-hocr, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fba), [a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd))
* **docs:** optimize PDF via Ghostscript pdfwrite ([6b3f40e](https://github.com/eSlider/go-onlyoffice/commit/6b3f40e))
* **security:** gitleaks secret-scan in CI + pre-push/pre-commit hooks ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554))
### Fixes
* **files:** `DeleteFiles` via per-file DELETE API; `DeleteDavItems` with `Immediately` true ([622dcdb](https://github.com/eSlider/go-onlyoffice/commit/622dcdb), [d9a7adc](https://github.com/eSlider/go-onlyoffice/commit/d9a7adc))
* **docs:** put-txt preserves line breaks in DOCX; fixed-width extracts in code block; put-md upsert by stem ([309ae44](https://github.com/eSlider/go-onlyoffice/commit/309ae44), [f1739dc](https://github.com/eSlider/go-onlyoffice/commit/f1739dc), [50bd475](https://github.com/eSlider/go-onlyoffice/commit/50bd475))
* **ci:** point gitleaks at `/github/workspace` in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d))
## [0.14.0](https://github.com/eSlider/go-onlyoffice/compare/v0.13.0...v0.14.0) (2026-08-27)
### Features
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
* **docs:** hOCR → Markdown via go-hocr ([e531de2](https://github.com/eSlider/go-onlyoffice/commit/e531de280f2c521a7411f75212803649b177db58))
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
* **docs:** oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML ([a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd61c12be9098c8d16e7c1c1253eb460816))
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
### Bug Fixes
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
* **docs:** put-md upsert — no duplicate folder files ([2b267d3](https://github.com/eSlider/go-onlyoffice/commit/2b267d36a8086b14a94706468f7e92e9179e62d4))
* **docs:** put-md upsert by stem to avoid duplicate folder files ([50bd475](https://github.com/eSlider/go-onlyoffice/commit/50bd47570b6430cebde43641a7b1d2f8d54d32f2))
* **files:** DeleteFiles actually removes files on produktor OO ([a8cb4e9](https://github.com/eSlider/go-onlyoffice/commit/a8cb4e97805226e8af694ec6a64ee3ac6a5e9fdd))
* **files:** DeleteFiles via per-file DELETE API ([622dcdb](https://github.com/eSlider/go-onlyoffice/commit/622dcdb7bffd49acbea5ef07f5d5d009f44b1358))
### Documentation
* **readme:** document oo docs hocr ([70e605b](https://github.com/eSlider/go-onlyoffice/commit/70e605b4ca8ee005e4351b2fc2d2eec081604998))
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
## [0.13.0](https://github.com/eSlider/go-onlyoffice/compare/v0.12.0...v0.13.0) (2026-08-27)
### Features
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
* **docs:** hOCR → Markdown via go-hocr ([e531de2](https://github.com/eSlider/go-onlyoffice/commit/e531de280f2c521a7411f75212803649b177db58))
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
* **docs:** oo docs hocr — tesseract hOCR → go-hocr Markdown/YAML ([a264cbd](https://github.com/eSlider/go-onlyoffice/commit/a264cbd61c12be9098c8d16e7c1c1253eb460816))
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
### Bug Fixes
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
* **files:** rewrite viewUrl host to API base on download ([ecf34ba](https://github.com/eSlider/go-onlyoffice/commit/ecf34ba51aae77f1626a60795f7329f25d9344e5))
### Documentation
* **readme:** document oo docs hocr ([70e605b](https://github.com/eSlider/go-onlyoffice/commit/70e605b4ca8ee005e4351b2fc2d2eec081604998))
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
## [0.12.0](https://github.com/eSlider/go-onlyoffice/compare/v0.11.0...v0.12.0) (2026-08-27)
### Features
* **crm:** contact email & person-opportunity indexes, history entity whitelist ([9c450a0](https://github.com/eSlider/go-onlyoffice/commit/9c450a0352c25f12ac16d91062744ab026ffe660))
* **docs:** md↔docx convert, OCR→PDF, as-md/put-md for agents ([6ea2fba](https://github.com/eSlider/go-onlyoffice/commit/6ea2fbadf768d7d85f6c7a49a9bc1050e642bc56))
* **docs:** md↔docx, OCR→PDF, as-md/put-md ([db9ef12](https://github.com/eSlider/go-onlyoffice/commit/db9ef12ff4096f8aadb557ca128b6edcb503028c))
* **files:** add WebDAV-oriented Files operations ([ebbd5d5](https://github.com/eSlider/go-onlyoffice/commit/ebbd5d5373abfeca5f160312f201cbd683f55e42))
* **mail:** oo mails send — SendMail + guard empty-by-id send ([dda2bd3](https://github.com/eSlider/go-onlyoffice/commit/dda2bd3ca5cea6ebb89e1ac02b785b9838727bda))
* **mail:** oo mails send — SendMail client + guard empty-by-id ([ab7dfbd](https://github.com/eSlider/go-onlyoffice/commit/ab7dfbd8594de5724286e2b460e789f4acb114bc))
* **security:** secret-scan via gitleaks in CI + pre-push/pre-commit hooks ([#142](https://github.com/eSlider/go-onlyoffice/issues/142)) ([9ead554](https://github.com/eSlider/go-onlyoffice/commit/9ead554f5cc229687d3a27934a023fb1dddaef15))
### Bug Fixes
* **ci:** point gitleaks at /github/workspace in docker action ([2c0df0d](https://github.com/eSlider/go-onlyoffice/commit/2c0df0d55fb03555b60481695554d75dceffce48))
* **files:** rewrite viewUrl host to API base on download ([ecf34ba](https://github.com/eSlider/go-onlyoffice/commit/ecf34ba51aae77f1626a60795f7329f25d9344e5))
### Documentation
* **readme:** document oo docs md↔docx / OCR agent workflow ([239c5ea](https://github.com/eSlider/go-onlyoffice/commit/239c5ea67201e62de6ba067f0e7963c7c9bda188))
## [0.11.0](https://github.com/eSlider/go-onlyoffice/compare/v0.10.0...v0.11.0) (2026-08-18)
### Features
* **catalog:** port oo catalog (scan/merge/match/apply) from legacy branch ([256864f](https://github.com/eSlider/go-onlyoffice/commit/256864f613f31570cf995dddc51255a9a91a38f8))
## [0.10.0](https://github.com/eSlider/go-onlyoffice/compare/v0.9.0...v0.10.0) (2026-08-13)
### Features
* **crm:** add invoices CLI and opportunity update ([19a7e9c](https://github.com/eSlider/go-onlyoffice/commit/19a7e9cbf8f7bd335c4e4cfd4d3b8a84a350cc41))
* **crm:** allow updating invoice terms via oo ([31c556d](https://github.com/eSlider/go-onlyoffice/commit/31c556d34cedd65d6957441f448520af9a7e380b))
* **crm:** invoice update notes and PO fields ([d81e7de](https://github.com/eSlider/go-onlyoffice/commit/d81e7de9114a29439142efba67f205653f562730))
* **crm:** invoices CLI and opportunity update ([f958309](https://github.com/eSlider/go-onlyoffice/commit/f95830961d3aecba65f3cb074d33c9c98f6c6cf2))
* **mail:** draft, attach, and draft-invoice CLI ([73b1050](https://github.com/eSlider/go-onlyoffice/commit/73b1050a83e9e49dcf99d8a966a84746376d7dd9))
### Bug Fixes
* **crm:** JSON person update and oo persons update CLI ([42c31d4](https://github.com/eSlider/go-onlyoffice/commit/42c31d4b485bfab6903aeed0dd6fb2c43f61b4f2))
* **crm:** link invoices to opportunities ([3201a58](https://github.com/eSlider/go-onlyoffice/commit/3201a58df75bceb7d41034970cf384a9169163ed))
* **mail:** German spacing for invoice draft template ([7f35b0e](https://github.com/eSlider/go-onlyoffice/commit/7f35b0e91075d871f87de95b6c84b2353e02f9cf))
### Documentation
* **crm:** capture association graph and invoice/mail quirks ([490426c](https://github.com/eSlider/go-onlyoffice/commit/490426c2779160f1a9b168f6ccae9dd8dccf89e9))
* **crm:** clarify mail API send vs signature for chat links ([6b5a82b](https://github.com/eSlider/go-onlyoffice/commit/6b5a82bef831341aee16b0b0ea1fe6db2c24567d))
* **crm:** Team vs Contacts and persons update note ([11aa23f](https://github.com/eSlider/go-onlyoffice/commit/11aa23f1a17471bad2bd95fddb769a94afbcf97a))
## [0.9.0](https://github.com/eSlider/go-onlyoffice/compare/v0.8.3...v0.9.0) (2026-07-26)
### Features
* **catalog:** mbox header scan, company legal-suffix match, apply order ([8b29be2](https://github.com/eSlider/go-onlyoffice/commit/8b29be2d9bb71154d473f780ad247c5c440c6021))
* **catalog:** scan Thunderbird address books and Gloda contacts ([502b373](https://github.com/eSlider/go-onlyoffice/commit/502b373e3726ded8b9c50e061ff30aea83205a34))
* **oo:** catalog scan/match/apply for CRM clients and contacts ([547f063](https://github.com/eSlider/go-onlyoffice/commit/547f063f7fe922fe784358093a89730f9aaf0683))
* **oo:** catalog scan/match/apply for CRM clients and contacts ([c32e4dd](https://github.com/eSlider/go-onlyoffice/commit/c32e4dd6116f0b8df67e93c5b589a417dfee59aa))
* **projects:** CRM contacts + git author linking ([457c589](https://github.com/eSlider/go-onlyoffice/commit/457c589abdc2c9fe8edd42b4b7445581976cc5a2))
* **projects:** link CRM companies and git authors to projects ([5a8735e](https://github.com/eSlider/go-onlyoffice/commit/5a8735e2898cf53e330e183250cf3c5d3c84da55))
### Bug Fixes
* **catalog:** oo_projects field + safer match ([8d5ca67](https://github.com/eSlider/go-onlyoffice/commit/8d5ca67d64da83c70d8ae293f4c7c23f9f9f59d3))
* **catalog:** preserve oo_id on match; add oo_projects field ([d873cb7](https://github.com/eSlider/go-onlyoffice/commit/d873cb748adf4458369d8a2167abd8da987737c5))
* **crm:** clean person names; canonical project titles ([b9591f5](https://github.com/eSlider/go-onlyoffice/commit/b9591f5fc3efc13b78dbe932ff32c2f2905e18fc))
* **crm:** person names + CC | Company | Title projects ([6305135](https://github.com/eSlider/go-onlyoffice/commit/6305135bd799efe42dbb900a694e82aeae9f9e58))
## [0.8.3](https://github.com/eSlider/go-onlyoffice/compare/v0.8.2...v0.8.3) (2026-07-23)
### Bug Fixes
* **ci:** dispatch GoReleaser after release-please; allow Tests dispatch ([#13](https://github.com/eSlider/go-onlyoffice/issues/13)) ([bf2fa63](https://github.com/eSlider/go-onlyoffice/commit/bf2fa637aaf0419ba5af40b25b64f9ebe24116e8))
## [0.8.2](https://github.com/eSlider/go-onlyoffice/compare/v0.8.1...v0.8.2) (2026-07-23)
### Bug Fixes
* **ci:** retry tag fetch in Release workflow ([#11](https://github.com/eSlider/go-onlyoffice/issues/11)) ([31b4aa0](https://github.com/eSlider/go-onlyoffice/commit/31b4aa0d90393e6d93513284e453b7da7e92c62d))
## [0.8.1](https://github.com/eSlider/go-onlyoffice/compare/v0.8.0...v0.8.1) (2026-07-23)
### Bug Fixes
* **oo:** assign owner and deadline on task create ([#9](https://github.com/eSlider/go-onlyoffice/issues/9)) ([f608207](https://github.com/eSlider/go-onlyoffice/commit/f60820734924284d6c40b46c79380a24573432f3))
## [0.8.0](https://github.com/eSlider/go-onlyoffice/compare/v0.7.0...v0.8.0) (2026-07-23)
### Features
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
* **search:** add produktor SearXNG JSON client ([79b1a6b](https://github.com/eSlider/go-onlyoffice/commit/79b1a6b2c397c2a8f8aeef7e2d8e6d81e0a93db2))
* **search:** add produktor SearXNG JSON client ([bf144f2](https://github.com/eSlider/go-onlyoffice/commit/bf144f2bb37c44aa37ff33c6b03f59d7b5023dc7))
### Bug Fixes
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
### Code Refactoring
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
## [0.7.0](https://github.com/eSlider/go-onlyoffice/compare/v0.6.0...v0.7.0) (2026-07-23)
### Features
* **office:** mail preview, infinite scroll, pane resize, and scrollbars ([72844df](https://github.com/eSlider/go-onlyoffice/commit/72844dfe243e04c61ea9bc4db66c55abc08d3ae8))
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
### Bug Fixes
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
### Code Refactoring
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
### Features
* **office:** table detail panes and Alt+1/2/3 layout toggles ([934af21](https://github.com/eSlider/go-onlyoffice/commit/934af21bc93a54df9f40aacb4d89468b00d7562e))
### Documentation
* CHANGELOG for office v0.5.1 release ([2985070](https://github.com/eSlider/go-onlyoffice/commit/29850701f8c9ab0c6d6427f63eaa189a1c558140))
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
### Features
* **office:** scrollable panes, nav tree drill-down, and item actions ([fe2ee48](https://github.com/eSlider/go-onlyoffice/commit/fe2ee481b9c0f959b798fd40679084a8c4bc4ba2))
## [0.4.0](https://github.com/eSlider/go-onlyoffice/compare/v0.3.2...v0.4.0) (2026-06-24)
### ⚠ BREAKING CHANGES
* **cli:** subject-based command tree (tea-style) + global --output flag
* rename oo-cli → oo, split library by domain, relocate applications
### Features
* **cli:** subject-based command tree (tea-style) + global --output flag ([1e6d22f](https://github.com/eSlider/go-onlyoffice/commit/1e6d22f38dd7a70d4cf1b69ef0b8749b01fded1a))
* **crm:** dedupe duplicates, fix deal titles, and add cleanup CLI ([c77519f](https://github.com/eSlider/go-onlyoffice/commit/c77519fac5ae9dffaad3f1f0ae722d969e62dde9))
* **crm:** merge company slogan variants in dedupe grouping ([3eb649c](https://github.com/eSlider/go-onlyoffice/commit/3eb649c58bf489b221f90734e04f33063f736914))
* **files:** project/task Documents API + oo projects|tasks files ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
* **mails:** add Workspace mail CLI with pagination and parsed from fields ([1cb228e](https://github.com/eSlider/go-onlyoffice/commit/1cb228e5d83ab453660bbace5f936a77f9381fc2))
* **office:** add Workspace TUI with shared bootstrap and test suite ([7358ff5](https://github.com/eSlider/go-onlyoffice/commit/7358ff5c326cdc873cb68b3f96d31155ecba1ee3))
### Code Refactoring
* rename oo-cli → oo, split library by domain, relocate applications ([cff8145](https://github.com/eSlider/go-onlyoffice/commit/cff8145f8c35656a77b7a36338428bf9be0eda0f))
### Documentation
* CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree. ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
## [Unreleased]
## [0.7.0] — 2026-06-24
### Added — `office` TUI
- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes.
- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears.
- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane.
- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list.
- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport.
- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d).
- **Tasks** — humanized status labels and relative deadlines in the list.
- **Projects** — open/closed row colors; status toggle in the detail form; **Save** sends `responsibleId` and status via `UpdateProjectStatus`.
### Changed — `office` TUI
- Flattened navigation: **Projects**, **Tasks**, **By project**, **Calendar**, **CRM**, **Mail**, **Users** (removed Browse; Calendar no longer splits Calendars/Events).
- Middle pane truncates overflowing cells with `…`; full text on the cursor or Space-selected row.
- Detail form tab order: Title → Description → Status → Save → Delete.
- **j/k** scrolls mail/file preview and read-only detail forms; mouse wheel scrolls detail when hovering the content area.
### Added — library
- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle.
## [0.5.1] — 2026-06-24
### Added — `office` TUI
- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
- Visible panes share **100% terminal width** evenly; pane content fills its column.
- Tab focus skips hidden panes.
### Changed — `office` TUI
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
- Middle pane: multi-column table with sort, selection, and full-width columns.
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
- Row selection auto-loads detail; files show document preview, entities show forms.
## [0.6.0] - 2026-04-24
### Added — library
- **Project & task Documents API** in [`files.go`](files.go):
- `FileEntry`, `FolderEntry`, `ProjectFilesResponse` types.
- `GetProjectFiles`, `GetTaskFiles`, `GetFile`.
- `UploadProjectFile` — `POST /api/2.0/files/{folderId}/upload` into the
project's `projectFolder` (resolved via `GetProjectByID` or first folder
from `GetProjectFiles`).
- `AttachFilesToTask` — `POST .../project/task/{id}/files` with form
`files=<id>` (OnlyOffice expects **existing** file ids, not multipart).
- `UploadTaskFile` — uploads via `UploadProjectFile` using the task's
`projectOwner.id`, then attaches.
- `DetachTaskFile` — `DELETE .../files?fileid=`.
- `RenameFile` — `PUT /api/2.0/files/file/{id}.json` with JSON body.
- `DeleteFiles` — `PUT /api/2.0/files/fileops/delete.json` with `fileIds`.
- `DownloadFile` — `GetFile` then `GET` on `viewUrl` with `Authorization`.
- Helpers: `FileEntryNumericID`, `FileEntryTitle`, `SafeLocalFileName`.
- **`putJSON`** on `*Client` in [`http.go`](http.go) for JSON PUT bodies.
### Added — CLI
- `oo projects files list|upload|download|rename|delete` — see
[`cmd/oo/projects_files.go`](cmd/oo/projects_files.go); `list` supports
`--folders`.
- `oo tasks files list|upload|detach` — see [`cmd/oo/tasks_files.go`](cmd/oo/tasks_files.go).
### Added — tests
- [`files_integration_test.go`](files_integration_test.go) — live roundtrip
against OnlyOffice (same credential rules as `client_test.go`).
- [`files_test.go`](files_test.go) + `testdata/*.json` — envelope decode unit
tests (no network).
## [0.5.0] - 2026-04-24
### Changed — CLI **BREAKING**
- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun`
layout is replaced with `oo <subject> <verb>`:
| Old | New |
|---|---|
| `oo cal-list` | `oo calendar list` |
| `oo cal-events` | `oo calendar events` |
| `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` |
| `oo task-list` | `oo tasks list` |
| `oo task-add` | `oo tasks create` |
| `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) |
| `oo subtask-add` | `oo tasks subtask add` |
| `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) |
| `oo crm-add-contact --company …` | `oo companies create --name …` |
| `oo crm-add-contact --person-first …` | `oo persons create --first …` |
| `oo crm-deals` | `oo opportunities list` |
| `oo crm-deals --stages` | `oo opportunities stages` |
| `oo crm-add-deal` | `oo opportunities create` |
| `oo crm-cases` | `oo cases list` |
| `oo applications-sync` | `oo applications sync` |
- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`,
`oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks
{list,create,delete,categories}`, `oo cases {create,delete,member-add}`,
`oo contacts {get,info-add}`.
- **Global `--output/-o` flag**: all list-like commands now support
`--output table` (default; tabwriter-aligned, truncated to 80 chars
per cell) and `--output json`. Nested `bidCurrency` flattened to its
`abbreviation` in the table view.
- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`,
`oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`,
`oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that
removes things.
### Added
- `cmd/oo/common.go` — shared `printTable(headers, rows)` and
`printObject(v)` helpers that dispatch on the `--output` flag.
- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami`
via the library's `GetUsers` + `SelfUserID`.
- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`,
`UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`.
- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from
project `oo tasks`).
- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared
list/filter implementation.
### Changed — library (minor)
- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())`
so CLI aborts propagate to the auth request.
## [0.4.0] - 2026-04-24
### Changed — project structure
- **Library files reorganised by domain** (mechanical split; zero API surface
change). The former monolithic `onlyoffice.go` (687 LOC) is now split into:
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`,
`Permissions`, `requestBodyReader`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`,
`Auth`, `ensureToken`, `authHeader`, `tokenValid`.
- `http.go` — transport helpers and DRY response decoders
(`ResponseArray`, `ResponseObject`, `postFormObject`, `putFormObject`,
`deleteObject`, `unmarshalResponseObject`).
- `projects.go` — `Project`, `Projects`, `Milestone`, `ProjectOwner` +
`GetProjects` / `CreateProject` / `UpdateProject` / `DeleteProject` /
`GetProjectByID` / `GetProjectMilestones`.
- `tasks.go` — `Task`, `ProjectTaskStatus`, `TaskPriority`,
`ProjectGetTasksRequest` et al. **plus** the form-encoded helpers
formerly in `tasks_extra.go` (`ListTasks`, `AddTask`, `AddSubtask`,
`UpdateTaskStatus`, `DeleteTask`, `GetTaskByID`).
- `users.go` — `User`, `Contact`, `Group`, `GetUsers`, `SelfUserID`.
- `calendar.go`, `crm.go`, `files.go` — unchanged in scope, refactored
through the new DRY helpers.
- `httpx.go` → **renamed** `http.go`.
- `onlyoffice.go` and `tasks_extra.go` — **deleted** (content redistributed).
### Changed — CLI **BREAKING**
- **Binary renamed `oo-cli` → `oo`.** Install with
`go install github.com/eslider/go-onlyoffice/cmd/oo@latest`.
- **Package path `cmd/oo-cli` → `cmd/oo`.** The old path is removed.
- **`internal/cli` is gone.** Cobra commands now live directly under
`cmd/oo/` as `package main`, split by domain: `calendar.go`, `crm.go`,
`tasks.go`, `apps.go`, `common.go`. Rationale: cobra wiring is a CLI-only
concern and does not belong inside a `pkg-level internal/`.
- **`internal/applications` → `cmd/oo/applications/`.** This is a
CV-specific CRM workflow — not a general OnlyOffice feature — and is only
consumed by the `oo` CLI. Keeping it under `cmd/oo/` prevents accidental
external adoption and makes the coupling explicit.
- **`examples/applications/` removed.** It imported an internal package,
which was a policy smell. Remaining examples (`basic`, `calendar`, `crm`,
`subtasks`) use only the exported library surface.
### Added
- `(*Client).ResponseObject` — GET-and-decode-object counterpart to the
existing `ResponseArray`.
- `(*Client).postFormObject` / `putFormObject` / `deleteObject` — eliminate
the ~15 identical "form request → `responseField` → `json.Unmarshal`"
blocks previously duplicated across `crm.go` / `tasks_extra.go` /
`calendar.go` / `files.go`.
### Migration
External library consumers: **no changes required**. The module path
(`github.com/eslider/go-onlyoffice`), the `onlyoffice` package name, and
every exported symbol are unchanged.
CLI users: replace `oo-cli` with `oo` in scripts and CI. The command set
and flags are identical.
## [0.3.2] - 2026-04-24
### Fixed
- `Project.String()` no longer interprets the title as a format string
(`fmt.Sprintf(*p.Title)`) and is now nil-safe on a zero-value `Project`.
- `internal/applications.buildSummary` no longer panics at regex compile time
on Go 1.23+ — the previous `(?= ...)` lookahead is replaced with an RE2-safe
non-capturing trailing delimiter.
### Changed
- `Client.Query` now routes token acquisition through the shared
`ensureToken` path instead of duplicating the auth-expiry check inline.
- Request body marshalling is consolidated into an unexported
`requestBodyReader` helper (DRY; no change to the public surface).
- The `Request.Debug` field is preserved for backwards compatibility but no
longer changes behaviour — both branches used to unmarshal into the same
target value. We'll remove the field in a future major release.
### Tests
- Deleted `httptest.NewServer` fixtures that emulated OnlyOffice protocol
endpoints. Replaced them with:
- pure-Go unit tests in `unit_test.go` (no network);
- real integration tests in `client_test.go` guarded by
`//go:build integration`. Run with
`go test -tags=integration ./...`. Tests skip cleanly when
`ONLYOFFICE_URL/USER/PASS` (or aliases) are absent.
- New policy documented in `AGENTS.md` and
`.cursor/rules/no-synthetic-mocks.mdc`.
## [0.3.1] - 2026-04-24
### Added
- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and
deadlines. Preferred entry point for long-running syncs (cron, watchers).
- `InvalidateToken()` — clears the cached token to force re-auth on the next
request. Use this to recover from a mid-sync 401 when the server has revoked
the session while the local `Expires` timestamp still looks fresh.
### Notes
- Plain `Authenticate()` is unchanged and remains a convenience wrapper around
`AuthenticateContext(context.Background())`.
- No breaking changes; a patch release.
## [0.3.0] - 2026-04-24
### Added
- Calendar helpers: `ListCalendars`, `ListEvents`, `AddEvent`, `DeleteEvent`.
- CRM helpers: contacts (`ListContacts`, `GetContact`, `FindCompany`, `FindPerson`,
`CreateCompany`, `CreatePerson`, `AddContactInfo`, `DeleteContact`), deals
(`ListOpportunities`, `GetOpportunity`, `CreateOpportunity`,
`AddOpportunityMember`, `ListDealStages`, `DeleteOpportunity`), cases
(`ListCases`, `CreateCase`, `AddCaseMember`, `DeleteCase`), CRM tasks
(`ListCRMTasks`, `CreateCRMTask`, `DeleteCRMTask`, `ListTaskCategories`), and
history notes (`AddHistoryNote`).
- Project task extras: `GetProjectByID`, `ListTasks`, `ListAllTasks`,
`GetTaskByID`, `AddTask`, `AddSubtask`, `UpdateTaskStatus`, `DeleteTask`.
- File upload: `UploadOpportunityFile` (multipart).
- `SelfUserID` cached lookup of `people/@self`.
- `Defaults` struct + `SetDefaults` + `GetEnvironmentDefaults` for optional
calendar/project fallbacks.
- Alias env vars accepted by `GetEnvironmentCredentials`:
`ONLYOFFICE_HOST` / `ONLYOFFICE_NAME` / `ONLYOFFICE_PASSWORD`.
- Public `Authenticate()` that primes the token eagerly.
- Bundled CLI: `cmd/oo-cli` (Cobra) with commands `cal-list`, `cal-events`,
`cal-add`, `cal-delete`, `task-list`, `task-add`, `subtask-add`,
`task-update`, `crm-contacts`, `crm-add-contact`, `crm-deals`,
`crm-add-deal`, `crm-cases`, `applications-sync`.
- httptest-based unit tests for form / multipart / CRM helpers.
### Changed
- The `onlyoffice.Client` struct gained unexported fields (`defaults`, `selfID`,
`noteCatID`); the public API is unchanged and remains backwards compatible.
## [0.2.0] - earlier
- Badges, docs expansion (Gitea sync use case, Gantt/PM workflows).
## [0.1.0] - earlier
- Initial release: OnlyOffice Project Management API client.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Example Author Copyright (c) 2026 Andriy Oblivantsev
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+391 -3
View File
@@ -1,5 +1,12 @@
# go-onlyoffice # go-onlyoffice
[![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-onlyoffice.svg)](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8.svg)](https://go.dev)
[![Tests](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml/badge.svg)](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml)
[![Latest Release](https://img.shields.io/github/v/tag/eSlider/go-onlyoffice?sort=semver&label=release)](https://github.com/eSlider/go-onlyoffice/releases)
[![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-onlyoffice?style=social)](https://github.com/eSlider/go-onlyoffice/stargazers)
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically. Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically.
Pairs with [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) to bridge developer issue trackers with CRM-grade project management for Gantt charts, resource planning, and executive reporting. Pairs with [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) to bridge developer issue trackers with CRM-grade project management for Gantt charts, resource planning, and executive reporting.
@@ -504,19 +511,400 @@ type Task struct {
| `Time` | `time.Time` wrapper with OnlyOffice JSON format | | `Time` | `time.Time` wrapper with OnlyOffice JSON format |
| `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description | | `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description |
### Calendar, CRM, Subtasks, File upload (v0.2+)
Since v0.2 the library also exposes OnlyOffice Workspace surfaces beyond
Projects: Calendar events, CRM (Contacts, Companies, Opportunities, Cases,
Tasks, History notes) and opportunity file uploads. These helpers return
untyped `map[string]any` for flexibility; callers that need typed structs
should use the typed Project/Task API above.
```go
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
client.SetDefaults(onlyoffice.GetEnvironmentDefaults()) // optional
ctx := context.Background()
// Calendar
events, _ := client.ListEvents(ctx, "2025-01-01", "2025-12-31")
client.AddEvent(ctx, "", "Interview", "2025-06-10T10:00:00Z", "2025-06-10T11:00:00Z", "", false)
// CRM
deals, total, _ := client.ListOpportunities(ctx, 50, 0)
company, _ := client.FindCompany(ctx, "ACME")
// Subtasks (form-encoded)
client.AddSubtask(ctx, "4242", "Prepare notes")
```
### oo (bundled CLI)
A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the
library lives under [`cmd/oo`](cmd/oo/). The command tree is **subject-based**,
mirroring the [`tea`](https://gitea.com/gitea/tea) CLI:
```bash
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
# Global: every list-style command takes -o table|json (default: table)
oo whoami
oo users list
oo calendar events --start 2026-04-24 --end 2026-05-01
oo projects list
oo projects get 33
oo tasks list --all --verbose
oo tasks subtask add 4242 "Prepare notes"
oo persons create --first Jane --last Doe --email jane@example.com
oo companies create --name "Acme GmbH" --website https://acme.com
oo opportunities list
oo opportunities stages
oo cases list
oo crm-tasks categories
```
### office (TUI)
Terminal UI for browsing OnlyOffice Workspace — module tree (left), selectable
lists (center), and markdown preview (right). Uses the same `.env` credentials
as `oo`. Lives under [`cmd/office`](cmd/office/).
```bash
go install github.com/eslider/go-onlyoffice/cmd/office@latest
office
```
| Key | Action |
|---|---|
| `Tab` | Next pane (menu → list → preview) |
| `Shift+Tab` | Previous pane |
| `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
| `Enter` / `→` | Expand tree branch or open leaf list |
| `Space` | Toggle multi-select on list row |
| `Enter` / `a` | Action menu (view, delete, download, …) |
| `r` | Refresh current list |
| `q` | Quit |
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
```bash
ONLYOFFICE_DOCS_URL=https://docs.example.com
ONLYOFFICE_DOCS_SECRET=… # when JWT signing is enabled
```
Spreadsheet files: inline CSV/JSON preview in the right pane; install
[`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen
xlsx/csv viewing (`v` on a file row — coming in next iteration).
Integration tests for list loaders and preview (live API only — no mocks):
```bash
go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
```
**Project / task documents (`oo`):**
```bash
# Project Documents (files module)
oo projects files list 33
oo projects files upload 33 ./notes.docx
oo projects files download 12345 --to ./copy.docx
oo projects files rename 12345 notes-v2.docx
oo projects files delete 12345
oo projects files dedupe 7 # dry-run duplicate report
oo projects files dedupe 7 --apply # remove older stem|ext copies per folder
oo projects files dedupe 7 --cross --apply # cross-folder; keep non-_trash
# Agent document pipeline (md in git ↔ docx in OO; OCR scans)
oo docs tools
oo docs convert ./note.md # → note.docx
oo docs convert ./note.docx # → note.md
oo docs ocr ./scan.jpg --md ./scan.md # searchable PDF + markdown
oo docs hocr ./scan.jpg --lang spa --md ./scan.hocr.md --yaml ./scan.yml
oo docs put-md 7 ./OO-HONDA-7-INDEX.md --folder 490
oo docs as-md 2815 --to ./parte.md # download OO file as MD (OCR if needed)
oo docs as-md 307 --hocr --lang spa # OO download via go-hocr structure
oo projects files put-md 7 ./note.md # alias
oo projects files as-md 2815 # alias
oo tasks files list 208
oo tasks files upload 208 ./notes.pdf
oo tasks files detach 208 12345
```
| Subject | Verbs |
|---|---|
| `calendar` | `list`, `events`, `add`, `delete` |
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
| `users` | `list`, `self` (alias: `oo whoami`) |
| `contacts` | `list`, `get`, `delete`, `info-add`, `merge`, `dedupe-info` |
| `persons` | `list`, `create`, `delete`, `dedupe` |
| `companies` | `list`, `create`, `delete`, `dedupe`, `dedupe-persons` |
| `opportunities` | `list`, `get`, `create`, `delete`, `stages`, `member-add`, `dedupe`, `dedupe-members`, `fix-titles` |
| `invoices` | `list`, `get`, `create`, `update`, `pdf`, `pdf-cleanup`, `status`, `delete`, `items …` |
| `crm` | `cleanup` |
| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
The CLI reads only `.env` from the current working directory (godotenv is a
CLI-only concern — the library itself never loads dotfiles).
Canonical `ONLYOFFICE_*` variables win over aliases. Optional CLI-only aliases:
`OO_URL` / `OO_USER` / `OO_PASS` → `ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
Run `oo --help` or `oo <subject> --help` for the full command reference.
> **0.5.0 migration note:** the command tree was flattened per-subject. Old
> flat names (`oo cal-events`, `oo task-list`, `oo crm-contacts`, …) were
> replaced by subject-based equivalents (`oo calendar events`, `oo tasks list`,
> `oo contacts list`). Flags on leaf commands are unchanged.
## oo CLI use cases
The `oo` binary is the day-to-day operator interface. It loads credentials from
`.env` in the **current working directory** (copy from [`.env.example`](.env.example)):
```bash
cp .env.example .env
# ONLYOFFICE_URL=https://office.example.com
# ONLYOFFICE_USER=you@example.com
# ONLYOFFICE_PASS=…
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
oo whoami
```
Every list command accepts `-o table` (default) or `-o json` for scripting.
### CRM cleanup after imports or sync drift
**Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice,
the same email on a contact three times, deals titled ` @ Acme`, or the same
HR contact linked to a deal twice.
**One-shot fix** — runs every dedupe pass in order:
```bash
oo crm cleanup -o json
```
Steps inside `crm cleanup`:
| Step | What it does |
|------|----------------|
| `companies` | Merge companies with the same normalized name (slogan variants like `Affirm` / `Affirm — Fraud Engineering` count as one) |
| `persons` | Merge duplicate persons globally (same first+last) |
| `company-persons` | Merge duplicate persons under each company |
| `contact-info` | Remove duplicate email/phone/website rows |
| `opportunity-members` | Drop duplicate contacts on the same deal |
| `opportunities` | Merge duplicate deals by title |
| `fix-titles` | Repair malformed titles (` @ Company` → `Company`) |
**Targeted passes** when you only want one kind of fix:
```bash
# Duplicate company records
oo companies dedupe
# Same person entered twice under one employer
oo companies dedupe-persons
# Global person duplicates (same name, different ids)
oo persons dedupe
# Repeated email/phone rows on contacts
oo contacts dedupe-info
# Two deals with the same title
oo opportunities dedupe
# Same contact attached twice to one deal
oo opportunities dedupe-members
# Titles like " @ Acme" or extra whitespace
oo opportunities fix-titles
```
Merge two known company ids (keeps `INTO`):
```bash
oo contacts merge FROM_ID INTO_ID
```
Company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail rules and OO quirks:
[docs/crm-associations.md](docs/crm-associations.md).
### Invoices (`oo invoices`)
**Problem:** Bill a client deal as Draft, regenerate PDF, prune duplicate PDF
attachments, prepare OnlyOffice Mail — without inventing a second bill-to company.
```bash
# Always pass --opportunity at create (update --opportunity often HTTP 400)
oo invoices create --number INV-2026-01 --contact COMPANY_ID --item ITEM_ID \
--price 300 --opportunity DEAL_ID --language de-DE \
--line-description "…" --terms $'…' --description $'…' --po "Deal #DEAL_ID"
oo invoices pdf INVOICE_ID --force
oo invoices pdf-cleanup INVOICE_ID
oo invoices status INVOICE_ID --status draft
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
```
**Deal grouping flag** — when the same role at the same company created
separate deals (`Engineer @ Acme` vs `Engineer`):
```bash
oo opportunities dedupe --ignore-company-suffix
oo crm cleanup --ignore-company-suffix
```
**Inspect before/after:**
```bash
oo opportunities list --count 200
oo contacts get CONTACT_ID -o json
oo crm cleanup -o json
```
### Workspace mail (`oo mails`)
**Problem:** Mail lives in OnlyOffice Mail (`/addons/mail/#inbox`), bound to your
portal account — not a separate archive service. You want to list, read, or
remove messages from the shell.
Uses the same `ONLYOFFICE_*` credentials as every other `oo` command.
```bash
# Which mailbox is linked?
oo mails accounts
# Folder counters (inbox unread, trash size, …)
oo mails folders
# Latest inbox messages (API returns 25 per page; --limit paginates automatically)
oo mails list --folder inbox --limit 50
# Page through older mail
oo mails list --folder inbox --limit 100 --offset 100
# Other folders
oo mails list --folder sent --limit 20
oo mails list --folder spam --limit 100
# Read full message (subject, htmlBody, attachments metadata)
oo mails get 5664 -o json | jq '{subject, from, to, date}'
# Remove one or more messages (server moves to trash or deletes per Mail rules)
oo mails delete 5664
oo mails delete 5664 5663 5661
# Create / update a draft (HTML body field is API "body"; plain text is wrapped)
oo mails draft --to client@example.com --subject "Rechnung INV-2026-01" \
--body "Guten Tag,\n\nanbei die Rechnung.\n\nMit freundlichen Grüßen"
# Attach an OnlyOffice Files document (e.g. invoice PDF file id) to a draft
oo mails attach 7301 --file-id 12345
# Regenerate invoice PDF (force) + draft + attach (does not send)
oo mails draft-invoice --invoice 16 --to info@example.com
```
Matrix / chat URLs in signatures: use plain text
`chat: https://matrix.to/#/@user:server` — HTML `<a href="…#…">` truncates at `#`.
**Table output** splits the `from` header into `fromName` and `fromAddress`
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
API payload.
**Scripting example** — export today's inbox subjects:
```bash
oo mails list --folder inbox --limit 200 -o json \
| jq -r '.[] | "\(.id)\t\(.subject)"'
```
### Contacts, companies, and deals (everyday CRM)
```bash
# Search companies
oo companies list --search acme
# Create company + person with primary email
oo companies create --name "Acme GmbH" --website https://acme.com
oo persons create --first Jane --last Doe --email jane@acme.com --company-id 42
# Attach email or LinkedIn to existing contact
oo contacts info-add 42 --type Email --value jane@acme.com --primary
# Pipeline overview
oo opportunities list --count 100
oo opportunities stages
oo opportunities get 231 -o json
```
### Calendar and project ops
```bash
# Next week's events
oo calendar events --start 2026-06-24 --end 2026-07-01
# Schedule interview block
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
# Attach a file to a task
oo tasks files upload 208 ./notes.pdf
oo projects files list 33
```
### Suggested maintenance cadence
| When | Command |
|------|---------|
| After bulk CRM imports | `oo crm cleanup` |
| After bulk CSV import into CRM | `oo crm cleanup` |
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
| Before exec reporting | `oo opportunities list` + `oo projects list` |
## Environment Variables ## Environment Variables
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `ONLYOFFICE_URL` | OnlyOffice instance URL | | `ONLYOFFICE_URL` (or `ONLYOFFICE_HOST`) | OnlyOffice instance URL |
| `ONLYOFFICE_USER` | Login email or username | | `ONLYOFFICE_USER` (or `ONLYOFFICE_NAME`) | Login email or username |
| `ONLYOFFICE_PASS` | Password | | `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
| `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` |
Mail and CRM cleanup are documented in [oo CLI use cases](#oo-cli-use-cases) above. Personal disk inventory / dossier sync lives in the private `oo-workspace` (`oow`) tooling.
### CI / releases
GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go-config)):
| Workflow | Trigger | Purpose |
|---|---|---|
| `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` |
| `release-please.yml` | push to `main` | semver PR from conventional commits |
| `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries |
Repo setting required once: **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**.
Merge the release-please PR to tag a version; GoReleaser publishes assets to [GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases).
## Examples ## Examples
| Example | Description | | Example | Description |
|---|---| |---|---|
| [basic](examples/basic/) | List projects and users | | [basic](examples/basic/) | List projects and users |
| [calendar](examples/calendar/) | List calendars and events, create a new event |
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
| [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules |
| [`cmd/office`](cmd/office/) | Terminal UI — browse Workspace with markdown preview |
## Related Libraries ## Related Libraries
+121
View File
@@ -0,0 +1,121 @@
package onlyoffice
// Authentication primitives: token lifecycle, eager / context-aware auth, and
// token invalidation. Split out of http.go so auth concerns live together.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Auth authenticates using the given credentials and returns a fresh token.
// Most callers should not call this directly — use Authenticate /
// AuthenticateContext, or let Query fetch a token lazily. Kept exported for
// backwards compatibility.
func (c *Client) Auth(creds *Credentials) (*Token, error) {
t := &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: http.MethodPost,
Body: creds,
NoAuth: true,
}, &struct {
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token
// is fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron,
// watchers) because it guarantees that a stalled auth call will not block
// the caller past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.tokenValid() {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// tokenValid reports whether the cached token is present and not expired.
func (c *Client) tokenValid() bool {
return c.token != nil && !time.Time(c.token.Expires).Before(time.Now())
}
// ensureToken refreshes the authentication token when missing or expired.
// Mirrors the logic inline in Query() but is safe to call from helpers that
// bypass the typed Request abstraction.
func (c *Client) ensureToken() error {
if c.tokenValid() {
return nil
}
tok, err := c.Auth(c.credentials)
if err != nil {
return err
}
c.token = tok
return nil
}
// authHeader returns the value for the Authorization header, ensuring a token.
func (c *Client) authHeader() (string, error) {
if err := c.ensureToken(); err != nil {
return "", err
}
return c.token.Value, nil
}
+72
View File
@@ -0,0 +1,72 @@
package onlyoffice
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
// Empty start/end fall back to a wide default window (2020-01-01..2030-12-31),
// matching the Python reference implementation.
func (c *Client) ListCalendars(ctx context.Context, start, end string) ([]map[string]any, error) {
if start == "" {
start = "2020-01-01"
}
if end == "" {
end = "2030-12-31"
}
path := fmt.Sprintf("/api/2.0/calendar/calendars/%s/%s.json", url.PathEscape(start), url.PathEscape(end))
return c.ResponseArray(ctx, path)
}
// ListEvents is an alias for ListCalendars; retained for the Python-port CLI.
func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[string]any, error) {
return c.ListCalendars(ctx, start, end)
}
// AddEvent creates a simple one-shot calendar event. When calendarID is empty
// the configured default (SetDefaults or GetEnvironmentDefaults) is used.
// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601).
//
// The underlying API returns an array wrapper — this helper unwraps the first
// element for convenience.
func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) {
if calendarID == "" {
calendarID = c.defaults.CalendarID
}
if calendarID == "" {
return nil, fmt.Errorf("AddEvent: calendarID is required (pass explicitly or set via SetDefaults)")
}
fields := url.Values{}
fields.Set("name", title)
fields.Set("description", description)
fields.Set("startDate", start)
fields.Set("endDate", end)
fields.Set("repeatType", "")
fields.Set("alertType", "0")
fields.Set("isAllDayLong", strconv.FormatBool(allDay))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var arr []map[string]any
if err := json.Unmarshal(resp, &arr); err != nil {
return nil, err
}
if len(arr) == 0 {
return nil, fmt.Errorf("AddEvent: empty response")
}
return arr[0], nil
}
// DeleteEvent removes a single calendar event by its event ID.
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
}
+199
View File
@@ -0,0 +1,199 @@
package catalog
import (
"context"
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
)
// ApplyResult summarizes one apply pass.
type ApplyResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
DryRun bool `json:"dry_run"`
}
// ApplyApproved creates/updates OO contacts for entries with approve=true.
// Companies are applied before persons so Org links resolve in the same run.
func ApplyApproved(ctx context.Context, client *onlyoffice.Client, doc *Document, dryRun bool) (*ApplyResult, error) {
res := &ApplyResult{DryRun: dryRun}
order := make([]int, 0, len(doc.Entries))
for i, e := range doc.Entries {
if e.Kind == "company" {
order = append(order, i)
}
}
for i, e := range doc.Entries {
if e.Kind != "company" {
order = append(order, i)
}
}
for _, i := range order {
e := &doc.Entries[i]
if !e.Approve {
res.Skipped++
continue
}
if e.Status == "conflict" {
res.Skipped++
res.Errors = append(res.Errors, e.ID+": conflict — resolve manually")
continue
}
if dryRun {
if e.OOID != "" || e.Status == "exists" {
res.Updated++
} else {
res.Created++
}
continue
}
created, err := applyOne(ctx, client, e)
if err != nil {
res.Errors = append(res.Errors, e.ID+": "+err.Error())
continue
}
if created {
res.Created++
} else {
res.Updated++
}
}
return res, nil
}
func applyOne(ctx context.Context, client *onlyoffice.Client, e *Entry) (created bool, err error) {
switch e.Kind {
case "company":
return applyCompany(ctx, client, e)
case "person":
return applyPerson(ctx, client, e)
default:
return false, fmt.Errorf("unknown kind %q", e.Kind)
}
}
func applyCompany(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
name := strings.TrimSpace(e.Name)
if name == "" {
return false, fmt.Errorf("company missing name")
}
var co map[string]any
var err error
if e.OOID != "" {
co, err = client.GetContact(ctx, e.OOID)
} else {
co, err = client.FindCompany(ctx, name)
}
if err != nil {
return false, err
}
created := false
if co == nil {
co, err = client.CreateCompany(ctx, name)
if err != nil {
return false, err
}
created = true
}
id := contactIDString(co)
e.OOID = id
if err := ensureContactInfos(ctx, client, id, e); err != nil {
return created, err
}
e.Status = "applied"
if created {
e.Notes = "created"
} else {
e.Notes = "updated"
}
return created, nil
}
func applyPerson(ctx context.Context, client *onlyoffice.Client, e *Entry) (bool, error) {
first := strings.TrimSpace(e.First)
last := strings.TrimSpace(e.Last)
if first == "" && last == "" {
first, last = SplitDisplayName(e.Name)
}
if first == "" {
first = strings.TrimSpace(e.Name)
}
if first == "" {
return false, fmt.Errorf("person missing name")
}
if last == "" {
last = "-"
}
var p map[string]any
var err error
if e.OOID != "" {
p, err = client.GetContact(ctx, e.OOID)
} else if len(e.Emails) > 0 {
p, err = client.FindPersonByEmail(ctx, e.Emails[0])
} else {
p, err = client.FindPerson(ctx, first, last)
}
if err != nil {
return false, err
}
created := false
companyID := 0
if e.Org != "" {
if co, ferr := client.FindCompany(ctx, e.Org); ferr == nil && co != nil {
companyID, _ = strconv.Atoi(contactIDString(co))
}
}
if p == nil {
about := ""
if e.Org != "" {
about = "org: " + e.Org
}
p, err = client.CreatePerson(ctx, first, last, companyID, "", about)
if err != nil {
return false, err
}
created = true
}
id := contactIDString(p)
e.OOID = id
if err := ensureContactInfos(ctx, client, id, e); err != nil {
return created, err
}
e.Status = "applied"
if created {
e.Notes = "created"
} else {
e.Notes = "updated"
}
return created, nil
}
func ensureContactInfos(ctx context.Context, client *onlyoffice.Client, contactID string, e *Entry) error {
existing, err := client.GetContact(ctx, contactID)
if err != nil {
return err
}
for i, em := range e.Emails {
if onlyoffice.HasContactInfo(existing, "Email", em) {
continue
}
if _, err := client.AddContactInfo(ctx, contactID, "Email", em, "Work", i == 0); err != nil {
return fmt.Errorf("add email %s: %w", em, err)
}
}
for _, ph := range e.Phones {
if onlyoffice.HasContactInfo(existing, "Phone", ph) {
continue
}
if _, err := client.AddContactInfo(ctx, contactID, "Phone", ph, "Work", false); err != nil {
return fmt.Errorf("add phone %s: %w", ph, err)
}
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package catalog
import (
"os"
"path/filepath"
"testing"
)
func TestParseVCFFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.vcf")
body := `BEGIN:VCARD
VERSION:3.0
FN:Ada Lovelace
N:Lovelace;Ada;;;
EMAIL;TYPE=INTERNET:ada@example.com
TEL;TYPE=CELL:+1-555-0100
ORG:Analytical Engines
END:VCARD
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
entries, err := ParseVCFFile(path)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries", len(entries))
}
e := entries[0]
if e.Kind != "person" || e.First != "Ada" || e.Last != "Lovelace" {
t.Fatalf("unexpected entry: %+v", e)
}
if len(e.Emails) != 1 || e.Emails[0] != "ada@example.com" {
t.Fatalf("emails: %v", e.Emails)
}
if e.Org != "Analytical Engines" {
t.Fatalf("org: %q", e.Org)
}
if e.Approve {
t.Fatal("approve should default false")
}
}
func TestMergeDocs(t *testing.T) {
a := &Document{Entries: []Entry{{
ID: "person:ada@example.com", Kind: "person", First: "Ada", Emails: []string{"ada@example.com"},
Sources: []string{"a.vcf"}, Zone: "private", Role: "unknown",
}}}
b := &Document{Entries: []Entry{{
ID: "person:ada@example.com", Kind: "person", Last: "Lovelace", Phones: []string{"+1"},
Sources: []string{"folder/Ada"}, Zone: "warm", Role: "work", Approve: true,
}}}
m := MergeDocs(a, b)
if len(m.Entries) != 1 {
t.Fatalf("len=%d", len(m.Entries))
}
e := m.Entries[0]
if e.First != "Ada" || e.Last != "Lovelace" {
t.Fatalf("%+v", e)
}
if len(e.Sources) != 2 || len(e.Phones) != 1 || !e.Approve {
t.Fatalf("%+v", e)
}
if e.Zone != "warm" || e.Role != "work" {
t.Fatalf("zone/role: %s/%s", e.Zone, e.Role)
}
}
func TestScanContactsRoot(t *testing.T) {
root := t.TempDir()
vcfDir := filepath.Join(root, "Contacts VCF's")
if err := os.MkdirAll(vcfDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vcfDir, "bob.vcf"), []byte(`BEGIN:VCARD
VERSION:3.0
FN:Bob Builder
EMAIL:bob@work.test
END:VCARD
`), 0o644); err != nil {
t.Fatal(err)
}
personDir := filepath.Join(root, "Carol Smith")
if err := os.MkdirAll(personDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(personDir, "carol@example.com.txt"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
doc, err := ScanContactsRoot(root)
if err != nil {
t.Fatal(err)
}
if len(doc.Entries) < 2 {
t.Fatalf("expected >=2 entries, got %d: %+v", len(doc.Entries), doc.Entries)
}
}
func TestScanProjectsRoot(t *testing.T) {
root := t.TempDir()
repo := filepath.Join(root, "produktor-demo")
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
t.Fatal(err)
}
doc, err := ScanProjectsRoot(root, 3)
if err != nil {
t.Fatal(err)
}
found := false
for _, e := range doc.Entries {
if e.Kind == "company" && e.Name == "produktor-demo" {
found = true
if e.Role != "work" {
t.Fatalf("role=%q", e.Role)
}
}
}
if !found {
t.Fatalf("missing company: %+v", doc.Entries)
}
}
func TestEntryID(t *testing.T) {
if got := EntryID("person", "A@B.COM", ""); got != "person:a@b.com" {
t.Fatal(got)
}
if got := EntryID("company", "", "Acme Corp"); got != "company:acme corp" {
t.Fatal(got)
}
}
+133
View File
@@ -0,0 +1,133 @@
package catalog
import (
"context"
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
)
// MatchAgainstOO sets status/oo_id by email then normalized name.
func MatchAgainstOO(ctx context.Context, client *onlyoffice.Client, doc *Document) error {
all, err := client.ListAllContacts(ctx)
if err != nil {
return err
}
byEmail := map[string]map[string]any{}
byPersonName := map[string]map[string]any{}
byCompanyName := map[string]map[string]any{}
for _, c := range all {
id := contactIDString(c)
isCo, _ := c["isCompany"].(bool)
for _, em := range contactEmails(c) {
byEmail[NormalizeEmail(em)] = c
}
if isCo {
key := NormalizeName(fmt.Sprint(c["displayName"]))
if key != "" {
byCompanyName[key] = c
}
continue
}
first := strings.TrimSpace(fmt.Sprint(c["firstName"]))
last := strings.TrimSpace(fmt.Sprint(c["lastName"]))
key := NormalizeName(first + " " + last)
if key == "" {
key = NormalizeName(fmt.Sprint(c["displayName"]))
}
if key != "" {
byPersonName[key] = c
}
_ = id
}
for i := range doc.Entries {
e := &doc.Entries[i]
// preserve approve
matched := false
conflict := false
var oo map[string]any
for _, em := range e.Emails {
if c, ok := byEmail[NormalizeEmail(em)]; ok {
if oo != nil && contactIDString(oo) != contactIDString(c) {
conflict = true
}
oo = c
matched = true
}
}
if !matched {
if e.Kind == "company" {
if c, ok := byCompanyName[NormalizeName(e.Name)]; ok {
oo = c
matched = true
}
} else {
key := NormalizeName(strings.TrimSpace(e.First + " " + e.Last))
if key == "" {
key = NormalizeName(e.Name)
}
if c, ok := byPersonName[key]; ok {
oo = c
matched = true
}
}
}
if conflict {
e.Status = "conflict"
e.Notes = strings.TrimSpace(e.Notes + " email_matches_multiple_oo")
if oo != nil {
e.OOID = contactIDString(oo)
}
continue
}
if matched && oo != nil {
e.Status = "exists"
e.OOID = contactIDString(oo)
continue
}
e.Status = "new"
e.OOID = ""
}
return nil
}
func contactIDString(c map[string]any) string {
switch v := c["id"].(type) {
case float64:
return strconv.FormatInt(int64(v), 10)
case int:
return strconv.Itoa(v)
case string:
return v
default:
return fmt.Sprint(v)
}
}
func contactEmails(c map[string]any) []string {
var out []string
if em := strings.TrimSpace(fmt.Sprint(c["email"])); em != "" && em != "<nil>" {
out = append(out, em)
}
if em := strings.TrimSpace(fmt.Sprint(c["primaryEmail"])); em != "" && em != "<nil>" {
out = append(out, em)
}
for _, row := range onlyoffice.ContactInfoRows(c) {
t := strings.ToLower(fmt.Sprint(row["infoType"]))
if t != "email" {
continue
}
data := strings.TrimSpace(fmt.Sprint(row["data"]))
if data != "" {
out = append(out, data)
}
}
return uniqueEmails(out)
}
+26
View File
@@ -0,0 +1,26 @@
package catalog
import (
"testing"
)
func TestMatchAgainstOO_emailThenName(t *testing.T) {
// MatchAgainstOO needs a live client; unit-test the helpers used by matching.
c := map[string]any{
"id": float64(42),
"isCompany": false,
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com",
"commonData": []any{
map[string]any{"infoType": "Email", "data": "ada.alt@example.com"},
},
}
if contactIDString(c) != "42" {
t.Fatal(contactIDString(c))
}
emails := contactEmails(c)
if len(emails) < 2 {
t.Fatalf("%v", emails)
}
}
+238
View File
@@ -0,0 +1,238 @@
package catalog
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// ScanOptions controls Thunderbird / mbox extraction depth.
type ScanOptions struct {
// MboxHeaders, when true, also walks mbox-like files under the root and
// extracts From/To/Cc/Reply-To addresses (headers only, no bodies).
MboxHeaders bool
// MboxMaxBytes skips individual mbox files larger than this (0 = 256 MiB default).
MboxMaxBytes int64
}
// ScanThunderbirdRoot finds Thunderbird profiles under root and emits person rows
// from address books (*.mab) and Gloda global-messages-db.sqlite.
func ScanThunderbirdRoot(root string) (*Document, error) {
return ScanThunderbirdRootOpts(root, ScanOptions{})
}
// ScanThunderbirdRootOpts is ScanThunderbirdRoot with optional mbox header pass.
func ScanThunderbirdRootOpts(root string, opts ScanOptions) (*Document, error) {
root = filepath.Clean(root)
st, err := os.Stat(root)
if err != nil {
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("not a directory: %s", root)
}
if opts.MboxMaxBytes <= 0 {
opts.MboxMaxBytes = 256 << 20
}
var entries []Entry
seenDB := map[string]struct{}{}
seenMAB := map[string]struct{}{}
seenMbox := map[string]struct{}{}
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
name := d.Name()
if d.IsDir() {
switch name {
case "Cache", "cache2", "startupCache", "OfflineCache", "minidumps",
"crashes", "safebrowsing", "thumbnails", "chrome", "extensions",
"node_modules", ".git":
return filepath.SkipDir
}
return nil
}
lower := strings.ToLower(name)
switch {
case lower == "global-messages-db.sqlite":
if _, ok := seenDB[path]; ok {
return nil
}
seenDB[path] = struct{}{}
parsed, perr := parseGlodaContacts(path)
if perr != nil {
entries = append(entries, Entry{
ID: EntryID("person", "", filepath.Base(path)),
Kind: "person",
Name: "gloda",
Sources: []string{path},
Zone: "private",
Role: "unknown",
Notes: "gloda_parse_error: " + perr.Error(),
Status: "new",
})
return nil
}
entries = append(entries, parsed...)
case strings.HasSuffix(lower, ".mab"):
if _, ok := seenMAB[path]; ok {
return nil
}
seenMAB[path] = struct{}{}
parsed, perr := parseMABEmails(path)
if perr != nil {
return nil
}
entries = append(entries, parsed...)
case opts.MboxHeaders && isLikelyMboxFile(name, path):
if _, ok := seenMbox[path]; ok {
return nil
}
seenMbox[path] = struct{}{}
info, ierr := d.Info()
if ierr != nil {
return nil
}
if info.Size() > opts.MboxMaxBytes {
return nil
}
parsed, perr := parseMboxHeaderEmails(path)
if perr != nil {
return nil
}
entries = append(entries, parsed...)
}
return nil
})
if err != nil {
return nil, err
}
return MergeDocs(&Document{Entries: entries}), nil
}
func isLikelyMboxFile(name, path string) bool {
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".msf") || strings.HasSuffix(lower, ".sqlite") ||
strings.HasSuffix(lower, ".mab") || strings.HasSuffix(lower, ".json") ||
strings.HasSuffix(lower, ".dat") || strings.HasSuffix(lower, ".ini") ||
strings.HasSuffix(lower, ".log") {
return false
}
if strings.HasSuffix(lower, ".mbox") || strings.HasSuffix(lower, ".mbx") {
return true
}
// Thunderbird: …/ImapMail/<server>/INBOX or …/Mail/Local Folders/Inbox
// or …/INBOX.sbd/<folder>
sep := string(filepath.Separator)
norm := filepath.ToSlash(path)
if strings.Contains(norm, "/ImapMail/") || strings.Contains(norm, "/Mail/") {
if !strings.Contains(name, ".") {
return true
}
known := map[string]struct{}{
"inbox": {}, "sent": {}, "drafts": {}, "trash": {}, "archives": {}, "junk": {},
}
if _, ok := known[lower]; ok {
return true
}
}
if strings.Contains(filepath.Base(filepath.Dir(path)), ".sbd") && !strings.Contains(name, ".") {
return true
}
_ = sep
return false
}
// parseMboxHeaderEmails extracts addresses from From/To/Cc/Reply-To headers only.
func parseMboxHeaderEmails(path string) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
emails := map[string]struct{}{}
br := bufio.NewReaderSize(f, 1<<20)
inHeaders := false
var headerBuf strings.Builder
flushHeaders := func() {
if headerBuf.Len() == 0 {
return
}
block := headerBuf.String()
headerBuf.Reset()
for _, line := range strings.Split(block, "\n") {
lower := strings.ToLower(strings.TrimSpace(line))
if strings.HasPrefix(lower, "from:") || strings.HasPrefix(lower, "to:") ||
strings.HasPrefix(lower, "cc:") || strings.HasPrefix(lower, "reply-to:") ||
strings.HasPrefix(lower, "sender:") {
for _, m := range emailRE.FindAllString(line, -1) {
em := NormalizeEmail(m)
if !noisyEmail(em) {
emails[em] = struct{}{}
}
}
}
}
}
for {
line, err := br.ReadString('\n')
if len(line) > 0 {
// Classic mbox separator
if strings.HasPrefix(line, "From ") && (len(line) == 5 || line[5] != ':') {
flushHeaders()
inHeaders = true
continue
}
if inHeaders {
trimmed := strings.TrimRight(line, "\r\n")
if trimmed == "" {
flushHeaders()
inHeaders = false
continue
}
headerBuf.WriteString(trimmed)
headerBuf.WriteByte('\n')
// cap pathological headers
if headerBuf.Len() > 64<<10 {
flushHeaders()
inHeaders = false
}
}
}
if err == io.EOF {
flushHeaders()
break
}
if err != nil {
flushHeaders()
return nil, err
}
}
var out []Entry
for em := range emails {
org, zone, role := classifyMailIdentity("", em)
out = append(out, Entry{
ID: EntryID("person", em, ""),
Kind: "person",
Emails: []string{em},
Org: org,
Sources: []string{path},
Zone: zone,
Role: role,
Approve: false,
Status: "new",
Notes: "thunderbird_mbox_header",
})
}
return out, nil
}
+67
View File
@@ -0,0 +1,67 @@
package catalog
import (
"os"
"path/filepath"
"testing"
)
func TestParseMboxHeaderEmails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "INBOX")
body := `From - Mon Jul 1 00:00:00 2016
From: Axel Schaefer <axel.schaefer@wheregroup.com>
To: Andriy Oblivantsev <andriy.oblivantsev@wheregroup.com>
Cc: noreply@example.com, client@stadt-example.de
Subject: test
Body line ignored
From - Mon Jul 2 00:00:00 2016
From: Someone <paul.schmidt@wheregroup.com>
To: list@wheregroup.com
more body
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
ents, err := parseMboxHeaderEmails(path)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range ents {
if len(e.Emails) > 0 {
got[e.Emails[0]] = true
}
}
if !got["axel.schaefer@wheregroup.com"] || !got["andriy.oblivantsev@wheregroup.com"] {
t.Fatalf("%v", got)
}
if got["noreply@example.com"] {
t.Fatal("noreply should be filtered")
}
if !got["client@stadt-example.de"] {
t.Fatal("missing client")
}
}
func TestScanThunderbirdRootOptsMbox(t *testing.T) {
root := t.TempDir()
imap := filepath.Join(root, "ImapMail", "mail.example.com")
if err := os.MkdirAll(imap, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(imap, "INBOX"), []byte(
"From - x\nFrom: a@wheregroup.com\nTo: b@wheregroup.com\n\nbody\n",
), 0o644); err != nil {
t.Fatal(err)
}
doc, err := ScanThunderbirdRootOpts(root, ScanOptions{MboxHeaders: true})
if err != nil {
t.Fatal(err)
}
if len(doc.Entries) < 2 {
t.Fatalf("%+v", doc.Entries)
}
}
+127
View File
@@ -0,0 +1,127 @@
package catalog
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// ScanProjectsRoot finds git roots under root (max depth) and emits company rows.
func ScanProjectsRoot(root string, maxDepth int) (*Document, error) {
root = filepath.Clean(root)
if maxDepth <= 0 {
maxDepth = 4
}
st, err := os.Stat(root)
if err != nil {
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("not a directory: %s", root)
}
var entries []Entry
err = walkGitRoots(root, root, 0, maxDepth, &entries)
if err != nil {
return nil, err
}
// Also top-level dirs as company stubs (even without git) — coverage 1B.
dents, _ := os.ReadDir(root)
for _, d := range dents {
if !d.IsDir() || strings.HasPrefix(d.Name(), ".") {
continue
}
name := d.Name()
skip := map[string]struct{}{
"node_modules": {}, "vendor": {}, ".cache": {},
}
if _, ok := skip[name]; ok {
continue
}
path := filepath.Join(root, name)
id := EntryID("company", "", name)
role, zone := classifyProjectName(name, "")
entries = append(entries, Entry{
ID: id,
Kind: "company",
Name: name,
Sources: []string{path},
Zone: zone,
Role: role,
Status: "new",
Notes: "top_level_dir",
})
}
return MergeDocs(&Document{Entries: entries}), nil
}
func walkGitRoots(root, dir string, depth, maxDepth int, out *[]Entry) error {
if depth > maxDepth {
return nil
}
// is this a git root?
if st, err := os.Stat(filepath.Join(dir, ".git")); err == nil && (st.IsDir() || st.Mode().IsRegular()) {
name := filepath.Base(dir)
remote := gitRemoteOrigin(dir)
role, zone := classifyProjectName(name, remote)
*out = append(*out, Entry{
ID: EntryID("company", "", name),
Kind: "company",
Name: name,
Sources: []string{dir},
Remote: remote,
GitRoot: dir,
Zone: zone,
Role: role,
Status: "new",
})
return nil // do not descend into nested repos from a git root
}
dents, err := os.ReadDir(dir)
if err != nil {
return nil
}
for _, d := range dents {
if !d.IsDir() {
continue
}
name := d.Name()
if name == ".git" || name == "node_modules" || name == "vendor" || name == ".venv" || name == "dist" {
continue
}
_ = walkGitRoots(root, filepath.Join(dir, name), depth+1, maxDepth, out)
}
return nil
}
func gitRemoteOrigin(dir string) string {
cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin")
b, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func classifyProjectName(name, remote string) (role, zone string) {
lower := strings.ToLower(name)
remoteL := strings.ToLower(remote)
switch {
case strings.Contains(lower, "experiment") || strings.HasPrefix(lower, "test"):
return "experiment", "cold"
case lower == "mama" || lower == "personal" || strings.Contains(lower, "private"):
return "personal", "private"
case strings.Contains(remoteL, "git.produktor.io") || strings.Contains(remoteL, "github.com/eslider"):
return "work", "hot"
case strings.Contains(lower, "produktor") || strings.Contains(lower, "eslider") ||
strings.Contains(lower, "asesoria") || strings.Contains(lower, "dyvenia") ||
strings.Contains(lower, "onlyoffice"):
return "work", "warm"
default:
return "unknown", "warm"
}
}
+191
View File
@@ -0,0 +1,191 @@
package catalog
import (
"database/sql"
"os"
"regexp"
"strings"
_ "modernc.org/sqlite"
)
var emailRE = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`)
// noisyEmail reports addresses that should not become CRM catalog persons.
func noisyEmail(email string) bool {
e := NormalizeEmail(email)
if e == "" || !strings.Contains(e, "@") {
return true
}
local, domain, ok := strings.Cut(e, "@")
if !ok {
return true
}
noiseLocal := []string{
"noreply", "no-reply", "donotreply", "mailer-daemon", "postmaster",
"bounce", "notifications", "newsletter", "unsubscribe",
"root", "admin", "abuse", "webmaster", "hostmaster",
}
for _, p := range noiseLocal {
if local == p || strings.Contains(local, p) {
return true
}
}
// role / shared mailboxes (keep human dotted names)
roleExact := map[string]struct{}{
"info": {}, "alle": {}, "all": {}, "office": {}, "office@": {},
"wartung": {}, "starface": {}, "gitlab": {}, "pl": {}, "gf": {},
"umsetzung": {}, "support": {}, "sales": {}, "billing": {},
}
if _, ok := roleExact[local]; ok {
return true
}
if strings.HasSuffix(local, "-request") || strings.HasSuffix(local, "-owner") {
return true
}
noiseDomain := []string{
"marketplace.amazon.", "reply.github.com", "users.noreply.github.com",
"groups.facebook.com", "googlegroups.com", "yahoogroups.",
"email.apple.com", "amazonses.com", "mailchimp.com",
"sendgrid.net", "mailgun.org", "postmarkapp.com",
"property.booking.com", "transvascular.com",
}
for _, p := range noiseDomain {
if strings.Contains(domain, p) {
return true
}
}
// random marketplace / tracking locals
if len(local) >= 16 && !strings.Contains(local, ".") && !strings.Contains(local, "-") {
if strings.Contains(domain, "amazon.") {
return true
}
}
return false
}
func parseMABEmails(path string) ([]Entry, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
found := emailRE.FindAllString(string(b), -1)
var out []Entry
for _, raw := range found {
em := NormalizeEmail(raw)
if noisyEmail(em) {
continue
}
org, zone, role := classifyMailIdentity("", em)
out = append(out, Entry{
ID: EntryID("person", em, ""),
Kind: "person",
Emails: []string{em},
Org: org,
Sources: []string{path},
Zone: zone,
Role: role,
Approve: false,
Status: "new",
Notes: "thunderbird_mab",
})
}
return out, nil
}
func parseGlodaContacts(dbPath string) ([]Entry, error) {
// read-only URI; immutable=1 helps when WAL/shm are missing
dsn := "file:" + dbPath + "?mode=ro&_pragma=query_only(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
defer db.Close()
rows, err := db.Query(`
SELECT COALESCE(c.name, ''), i.value
FROM identities i
LEFT JOIN contacts c ON c.id = i.contactID
WHERE lower(i.kind) = 'email' AND i.value IS NOT NULL AND i.value != ''
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
var name, value string
if err := rows.Scan(&name, &value); err != nil {
return out, err
}
em := NormalizeEmail(value)
// Gloda sometimes stores "Name <email>" in value
if m := emailRE.FindString(em); m != "" {
em = NormalizeEmail(m)
}
if noisyEmail(em) {
continue
}
display := strings.TrimSpace(name)
// strip wrapping quotes / email leftovers
display = strings.Trim(display, `"'`)
if idx := strings.Index(display, "<"); idx > 0 {
display = strings.TrimSpace(display[:idx])
}
first, last := "", ""
if display != "" {
first, last = SplitDisplayName(display)
}
org, zone, role := classifyMailIdentity(display, em)
out = append(out, Entry{
ID: EntryID("person", em, display),
Kind: "person",
Name: display,
First: first,
Last: last,
Emails: []string{em},
Org: org,
Sources: []string{dbPath},
Zone: zone,
Role: role,
Approve: false,
Status: "new",
Notes: "thunderbird_gloda",
})
}
return out, rows.Err()
}
func classifyMailIdentity(name, email string) (org, zone, role string) {
em := NormalizeEmail(email)
_, domain, _ := strings.Cut(em, "@")
switch {
case domain == "wheregroup.com" || strings.Contains(strings.ToLower(name), "wheregroup"):
return "WhereGroup", "warm", "work"
case domain == "produktor.io" || domain == "eslider.de" || strings.HasSuffix(domain, ".produktor.io"):
return "produktor.io", "hot", "work"
case domain == "dyvenia.com":
return "Dyvenia", "warm", "work"
case domain == "immowelt.de" || domain == "immowelt.com":
return "Immowelt", "warm", "work"
case strings.HasSuffix(domain, ".de") && looksPublicSector(domain):
return domain, "warm", "work"
default:
return "", "private", "unknown"
}
}
func looksPublicSector(domain string) bool {
d := strings.ToLower(domain)
hints := []string{
"stadt-", "stadt.", "kreis-", "gemeinde", "landkreis",
"bund.", "bundes", "lvermgeo", "rvr.", "eba.",
}
for _, h := range hints {
if strings.Contains(d, h) {
return true
}
}
return false
}
+97
View File
@@ -0,0 +1,97 @@
package catalog
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestNoisyEmail(t *testing.T) {
if !noisyEmail("noreply@example.com") {
t.Fatal("expected noisy")
}
if !noisyEmail("x@marketplace.amazon.de") {
t.Fatal("amazon marketplace")
}
if noisyEmail("andriy.oblivantsev@wheregroup.com") {
t.Fatal("should keep wheregroup")
}
}
func TestParseMABEmails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "abook.mab")
body := `// mork junk
PrimaryEmail=andriy.oblivantsev@wheregroup.com
noreply@github.com
axel.schaefer@wheregroup.com
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
ents, err := parseMABEmails(path)
if err != nil {
t.Fatal(err)
}
if len(ents) != 2 {
t.Fatalf("got %d: %+v", len(ents), ents)
}
for _, e := range ents {
if e.Org != "WhereGroup" || e.Role != "work" {
t.Fatalf("%+v", e)
}
}
}
func TestParseGlodaContacts(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "global-messages-db.sqlite")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE contacts (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE identities (id INTEGER PRIMARY KEY, contactID INTEGER, kind TEXT, value TEXT);
INSERT INTO contacts VALUES (1, 'Axel Schaefer');
INSERT INTO identities VALUES (1, 1, 'email', 'axel.schaefer@wheregroup.com');
INSERT INTO contacts VALUES (2, 'Noise Bot');
INSERT INTO identities VALUES (2, 2, 'email', 'noreply@example.com');
`)
if err != nil {
t.Fatal(err)
}
_ = db.Close()
ents, err := parseGlodaContacts(dbPath)
if err != nil {
t.Fatal(err)
}
if len(ents) != 1 {
t.Fatalf("got %d %+v", len(ents), ents)
}
if ents[0].First != "Axel" || ents[0].Emails[0] != "axel.schaefer@wheregroup.com" {
t.Fatalf("%+v", ents[0])
}
}
func TestScanThunderbirdRoot(t *testing.T) {
root := t.TempDir()
prof := filepath.Join(root, "profile")
if err := os.MkdirAll(prof, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(prof, "abook.mab"), []byte("mail=paul.schmidt@wheregroup.com\n"), 0o644); err != nil {
t.Fatal(err)
}
doc, err := ScanThunderbirdRoot(root)
if err != nil {
t.Fatal(err)
}
if len(doc.Entries) < 1 {
t.Fatal(doc.Entries)
}
}
+211
View File
@@ -0,0 +1,211 @@
// Package catalog builds a YAML inventory of CRM persons/companies from
// filesystem contacts (VCF, folders) and project trees, then matches/applies
// against OnlyOffice CRM.
package catalog
import (
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Entry is one catalog row (person or company).
type Entry struct {
ID string `yaml:"id"`
Kind string `yaml:"kind"` // person | company
Name string `yaml:"name,omitempty"`
First string `yaml:"first,omitempty"`
Last string `yaml:"last,omitempty"`
Emails []string `yaml:"emails,omitempty"`
Phones []string `yaml:"phones,omitempty"`
Org string `yaml:"org,omitempty"`
Sources []string `yaml:"sources,omitempty"`
Zone string `yaml:"zone"`
Role string `yaml:"role"`
OOID string `yaml:"oo_id,omitempty"`
Approve bool `yaml:"approve"`
Status string `yaml:"status,omitempty"` // new | exists | conflict | applied | skipped
Notes string `yaml:"notes,omitempty"`
Remote string `yaml:"remote,omitempty"`
GitRoot string `yaml:"git_root,omitempty"`
}
// Document is the on-disk catalog file.
type Document struct {
GeneratedAt string `yaml:"generated_at"`
Entries []Entry `yaml:"entries"`
}
// LoadYAML reads a catalog document from path.
func LoadYAML(path string) (*Document, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc Document
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
return &doc, nil
}
// SaveYAML writes the catalog document.
func SaveYAML(path string, doc *Document) error {
doc.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
b, err := yaml.Marshal(doc)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// NormalizeEmail lowercases and trims.
func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// NormalizeName collapses whitespace and lowercases for matching.
func NormalizeName(s string) string {
fields := strings.Fields(strings.ToLower(strings.TrimSpace(s)))
return strings.Join(fields, " ")
}
// SplitDisplayName splits "First Last …" into first/last (last = remainder).
func SplitDisplayName(name string) (first, last string) {
parts := strings.Fields(strings.TrimSpace(name))
if len(parts) == 0 {
return "", ""
}
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
// EntryID builds a stable-ish id from kind + email or name.
func EntryID(kind, email, name string) string {
if e := NormalizeEmail(email); e != "" {
return fmt.Sprintf("%s:%s", kind, e)
}
return fmt.Sprintf("%s:%s", kind, NormalizeName(name))
}
// MergeDocs unions entries by id; later sources append sources[] and fill empties.
func MergeDocs(docs ...*Document) *Document {
byID := map[string]*Entry{}
order := []string{}
for _, d := range docs {
if d == nil {
continue
}
for i := range d.Entries {
e := d.Entries[i]
id := e.ID
if id == "" {
primary := ""
if len(e.Emails) > 0 {
primary = e.Emails[0]
}
name := e.Name
if name == "" {
name = strings.TrimSpace(e.First + " " + e.Last)
}
id = EntryID(e.Kind, primary, name)
e.ID = id
}
if prev, ok := byID[id]; ok {
mergeEntry(prev, &e)
} else {
cp := e
byID[id] = &cp
order = append(order, id)
}
}
}
out := &Document{Entries: make([]Entry, 0, len(order))}
for _, id := range order {
out.Entries = append(out.Entries, *byID[id])
}
return out
}
func mergeEntry(dst, src *Entry) {
dst.Sources = uniqueStrings(append(dst.Sources, src.Sources...))
dst.Emails = uniqueEmails(append(dst.Emails, src.Emails...))
dst.Phones = uniqueStrings(append(dst.Phones, src.Phones...))
if dst.First == "" {
dst.First = src.First
}
if dst.Last == "" {
dst.Last = src.Last
}
if dst.Name == "" {
dst.Name = src.Name
}
if dst.Org == "" {
dst.Org = src.Org
}
if dst.Remote == "" {
dst.Remote = src.Remote
}
if dst.GitRoot == "" {
dst.GitRoot = src.GitRoot
}
if dst.Notes == "" {
dst.Notes = src.Notes
}
// Prefer more specific zone/role from project scans over default private.
if dst.Zone == "private" && src.Zone != "" && src.Zone != "private" {
dst.Zone = src.Zone
}
if dst.Role == "" || (dst.Role == "unknown" && src.Role != "" && src.Role != "unknown") {
dst.Role = src.Role
}
// Preserve approve/oo_id/status from whichever already set.
if !dst.Approve && src.Approve {
dst.Approve = true
}
if dst.OOID == "" {
dst.OOID = src.OOID
}
if dst.Status == "" {
dst.Status = src.Status
}
}
func uniqueEmails(in []string) []string {
seen := map[string]struct{}{}
var out []string
for _, e := range in {
e = NormalizeEmail(e)
if e == "" {
continue
}
if _, ok := seen[e]; ok {
continue
}
seen[e] = struct{}{}
out = append(out, e)
}
return out
}
func uniqueStrings(in []string) []string {
seen := map[string]struct{}{}
var out []string
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
+218
View File
@@ -0,0 +1,218 @@
package catalog
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/emersion/go-vcard"
)
// ParseVCFFile extracts person candidates from a vCard file (may contain many cards).
func ParseVCFFile(path string) ([]Entry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
dec := vcard.NewDecoder(f)
var out []Entry
for {
card, err := dec.Decode()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return out, fmt.Errorf("%s: %w", path, err)
}
e := entryFromCard(card, path)
if e.Name == "" && e.First == "" && len(e.Emails) == 0 {
continue
}
out = append(out, e)
}
return out, nil
}
func entryFromCard(card vcard.Card, source string) Entry {
fn := strings.TrimSpace(card.PreferredValue(vcard.FieldFormattedName))
n := card.Name()
first, last := "", ""
if n != nil {
first = strings.TrimSpace(n.GivenName)
last = strings.TrimSpace(n.FamilyName)
}
if fn == "" {
fn = strings.TrimSpace(first + " " + last)
}
if first == "" && last == "" && fn != "" {
first, last = SplitDisplayName(fn)
}
var emails, phones []string
for _, v := range card.Values(vcard.FieldEmail) {
if e := NormalizeEmail(v); e != "" {
emails = append(emails, e)
}
}
for _, v := range card.Values(vcard.FieldTelephone) {
p := strings.TrimSpace(v)
if p != "" {
phones = append(phones, p)
}
}
org := strings.TrimSpace(card.PreferredValue(vcard.FieldOrganization))
primary := ""
if len(emails) > 0 {
primary = emails[0]
}
return Entry{
ID: EntryID("person", primary, fn),
Kind: "person",
Name: fn,
First: first,
Last: last,
Emails: uniqueEmails(emails),
Phones: uniqueStrings(phones),
Org: org,
Sources: []string{source},
Zone: "private",
Role: "unknown",
Approve: false,
Status: "new",
}
}
// ScanContactsRoot walks a contacts directory: *.vcf anywhere, person-named
// subdirs, and *@*.txt email hint files.
func ScanContactsRoot(root string) (*Document, error) {
root = filepath.Clean(root)
st, err := os.Stat(root)
if err != nil {
return nil, err
}
if !st.IsDir() {
return nil, fmt.Errorf("not a directory: %s", root)
}
var entries []Entry
seenVCF := map[string]struct{}{}
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable
}
if d.IsDir() {
base := d.Name()
if base == ".git" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}
name := d.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".vcf") {
if _, ok := seenVCF[path]; ok {
return nil
}
seenVCF[path] = struct{}{}
parsed, perr := ParseVCFFile(path)
if perr != nil {
entries = append(entries, Entry{
ID: EntryID("person", "", filepath.Base(path)),
Kind: "person",
Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
Sources: []string{path},
Zone: "private",
Role: "unknown",
Notes: "vcf_parse_error: " + perr.Error(),
Status: "new",
})
return nil
}
entries = append(entries, parsed...)
return nil
}
// email hint files: name contains @ and ends with .txt
if strings.Contains(name, "@") && strings.HasSuffix(lower, ".txt") {
email := NormalizeEmail(strings.TrimSuffix(name, filepath.Ext(name)))
if !strings.Contains(email, "@") {
return nil
}
parent := filepath.Base(filepath.Dir(path))
first, last := SplitDisplayName(parent)
entries = append(entries, Entry{
ID: EntryID("person", email, parent),
Kind: "person",
Name: parent,
First: first,
Last: last,
Emails: []string{email},
Sources: []string{path},
Zone: "private",
Role: "unknown",
Status: "new",
})
}
return nil
})
if err != nil {
return nil, err
}
// Top-level person folders without VCF still become stub persons.
dents, _ := os.ReadDir(root)
for _, d := range dents {
if !d.IsDir() {
continue
}
name := d.Name()
if name == "Contacts VCF's" || strings.HasPrefix(name, ".") {
continue
}
// skip obvious non-person dumps
lower := strings.ToLower(name)
if strings.Contains(lower, "vcf") {
continue
}
first, last := SplitDisplayName(name)
id := EntryID("person", "", name)
entries = append(entries, Entry{
ID: id,
Kind: "person",
Name: name,
First: first,
Last: last,
Sources: []string{filepath.Join(root, name)},
Zone: "private",
Role: "unknown",
Status: "new",
Notes: "folder_stub",
})
}
doc := MergeDocs(&Document{Entries: entries})
return doc, nil
}
// ReadEmailsFromTxt reads loose emails from a text file (one per line or free text).
func ReadEmailsFromTxt(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.Contains(line, "@") && !strings.Contains(line, " ") {
out = append(out, NormalizeEmail(line))
}
}
return uniqueEmails(out), sc.Err()
}
+110
View File
@@ -0,0 +1,110 @@
// Package onlyoffice provides a Go client for the OnlyOffice Workspace
// (formerly ONLYOFFICE) REST API. It is organised as a flat single package
// intentionally: a single *Client exposes Projects, Tasks, Calendar, CRM and
// Files operations as receiver methods. Domain split is by file, not by
// subpackage, to keep call sites uniform (c.ListContacts, c.GetTasks,
// c.AddEvent, c.UploadOpportunityFile all live on the same handle).
//
// The CLI binary `oo` (see cmd/oo) is a thin cobra wrapper on top of this
// library and mirrors the Python cv/bin/office reference tooling.
package onlyoffice
import (
"net/http"
"net/http/cookiejar"
"os"
"strings"
)
// Client of OnlyOffice API uses credentials to get a token and query the API
// by every request.
//
// Construct with NewClient; optionally set fallbacks via SetDefaults. The
// zero value is NOT usable — credentials are required. A single client is
// safe for sequential use from one goroutine; for concurrent use, callers
// should wrap with their own synchronization or create one client per
// goroutine.
type Client struct {
client *http.Client
credentials *Credentials
token *Token
defaults Defaults // optional fallbacks for calendar/project IDs
selfID string // cached /api/2.0/people/@self id
noteCatID int // cached CRM history category id for "note"
}
// NewClient returns a new Client backed by http.DefaultClient.
func NewClient(c Credentials) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
client: &http.Client{Jar: jar},
credentials: &c,
}
}
// Credentials of OnlyOffice User. The Url field is NOT sent with the auth
// payload — it only determines the host.
type Credentials struct {
Url string `json:"-"`
User string `json:"userName"`
Password string `json:"password"`
}
// Defaults holds optional fallbacks used by package-level helpers when callers
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults
// or read from env via GetEnvironmentDefaults.
type Defaults struct {
CalendarID string
ProjectID string
}
// SetDefaults configures optional identifiers used as fallbacks by methods
// such as AddEvent (when calendarID == "") or AddTask (when projectID == "").
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
//
// Primary variables (documented):
// - ONLYOFFICE_URL
// - ONLYOFFICE_USER
// - ONLYOFFICE_PASS
//
// Additional aliases accepted for interoperability with sibling tools:
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
func GetEnvironmentCredentials() Credentials {
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
url = strings.TrimRight(url, "/")
return Credentials{
Url: url,
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
}
}
// GetEnvironmentDefaults reads optional library defaults from environment:
//
// - ONLYOFFICE_CALENDAR_ID (default: "1")
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
func GetEnvironmentDefaults() Defaults {
return Defaults{
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
ProjectID: firstNonEmpty(
os.Getenv("ONLYOFFICE_PROJECT_ID"),
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
"33",
),
}
}
// firstNonEmpty returns the first non-empty trimmed value, or "" if none.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
+191 -81
View File
@@ -1,128 +1,238 @@
//go:build integration
package onlyoffice package onlyoffice
// Integration tests — hit a live OnlyOffice Workspace instance. Credentials
// come from ONLYOFFICE_URL / ONLYOFFICE_USER / ONLYOFFICE_PASS (aliases
// _HOST / _NAME / _PASSWORD also accepted). Without credentials every test
// here skips.
//
// Run with:
//
// go test -tags=integration ./...
//
// These tests are destructive on the target instance. They create projects
// with titles prefixed "go-onlyoffice-test-" and clean up afterwards. Do not
// run against an instance you don't own.
import ( import (
"os" "context"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/joho/godotenv"
) )
// Load environment variables func skipWithoutCredentials(t *testing.T) Credentials {
func init() {
// Load from .env file in the project root
_ = godotenv.Load(".env")
}
func skipWithoutCredentials(t *testing.T) {
t.Helper() t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" { c := GetEnvironmentCredentials()
t.Skip("ONLYOFFICE_URL is not set, skipping integration test") if c.Url == "" || c.User == "" || c.Password == "" {
t.Skip("ONLYOFFICE_URL/USER/PASS not set — skipping integration test")
} }
return c
} }
func TestNewClient(t *testing.T) { func liveClient(t *testing.T) *Client {
skipWithoutCredentials(t) t.Helper()
c := NewClient(skipWithoutCredentials(t))
c.SetDefaults(GetEnvironmentDefaults())
return c
}
// Create a new OnlyOffice client const testProjectPrefix = "go-onlyoffice-test-"
credentials := GetEnvironmentCredentials()
client := NewClient(credentials)
var token *Token
var err error
token, err = client.Auth(&credentials)
func cleanupTestProjects(t *testing.T, c *Client) {
t.Helper()
projects, err := c.GetProjects()
if err != nil { if err != nil {
t.Errorf("Failed to get token: %v", err) t.Logf("cleanup: GetProjects: %v", err)
return
}
for _, p := range projects {
if p.Title != nil && strings.HasPrefix(*p.Title, testProjectPrefix) {
if _, err := c.DeleteProject(*p.ID); err != nil {
t.Logf("cleanup: DeleteProject %d: %v", *p.ID, err)
}
}
} }
if token == nil {
t.Error("Value is empty")
} }
// Clean up: remove all projects start with "Test project" func TestIntegrationAuthenticateContext(t *testing.T) {
projects, err := client.GetProjects() c := liveClient(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext: %v", err)
}
if c.token == nil || c.token.Value == "" {
t.Fatal("token not cached after AuthenticateContext")
}
// Second call must hit the cache.
cachedValue := c.token.Value
if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext (cached): %v", err)
}
if c.token.Value != cachedValue {
t.Fatal("cached token was replaced unexpectedly")
}
// Invalidate forces re-auth.
c.InvalidateToken()
if c.token != nil {
t.Fatal("InvalidateToken did not clear the cache")
}
if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext (after invalidate): %v", err)
}
if c.token == nil {
t.Fatal("post-invalidate auth left token nil")
}
}
func TestIntegrationGetProjectsAndSelf(t *testing.T) {
c := liveClient(t)
projects, err := c.GetProjects()
if err != nil { if err != nil {
t.Errorf("Failed to get projects: %v", err) t.Fatalf("GetProjects: %v", err)
} }
if projects == nil {
for _, project := range projects { t.Fatal("nil slice from GetProjects")
if strings.HasPrefix(*project.Title, "Test project") { }
prjStatus, err := client.DeleteProject(*project.ID) ctx := context.Background()
uid, err := c.SelfUserID(ctx)
if err != nil { if err != nil {
t.Errorf("Failed to delete project: %v", err) t.Fatalf("SelfUserID: %v", err)
}
if *prjStatus.ID != *project.ID {
t.Errorf("Project ID is not equal: %v != %v", prjStatus.ID, project.ID)
} }
if uid == "" {
t.Fatal("empty self user id")
} }
} }
// Test create project func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
project, err := client.CreateProject(NewProjectRequest{ c := liveClient(t)
Title: "Test project", t.Cleanup(func() { cleanupTestProjects(t, c) })
Description: "Test project description",
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "integration test from go-onlyoffice",
}) })
if err != nil { if err != nil {
t.Errorf("Failed to create project: %v", err) t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
} }
// Update project updated, err := c.UpdateProject(ProjectUpdateRequest{
project, err = client.UpdateProject(ProjectUpdateRequest{
ID: *project.ID, ID: *project.ID,
Title: "Test project updated", Title: title + " (updated)",
Description: "updated",
ResponsibleID: *project.Responsible.ID, ResponsibleID: *project.Responsible.ID,
}) })
if err != nil {
t.Fatalf("UpdateProject: %v", err)
}
if updated.Title == nil || *updated.Title != title+" (updated)" {
t.Errorf("title not updated: %+v", updated.Title)
}
var task *Task start := Time(time.Now().AddDate(0, 0, -2))
// Test create project task deadline := Time(time.Now().AddDate(0, 0, 2))
task, err = client.CreateProjectTask(NewProjectTaskRequest{ ms, err := c.CreateMilestone(NewMilestoneRequest{
ProjectID: *project.ID,
Title: "integration milestone",
Deadline: deadline,
})
if err != nil {
t.Fatalf("CreateMilestone: %v", err)
}
if ms == nil || ms.ID == nil {
t.Fatal("created milestone without id")
}
t.Cleanup(func() {
if err := c.DeleteMilestone(*ms.ID); err != nil {
t.Logf("cleanup DeleteMilestone: %v", err)
}
})
task, err := c.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID, ProjectId: *project.ID,
Title: "Test task", Title: "integration parent task",
Description: "Test task description", Description: "from go-onlyoffice integration suite",
Notify: true, StartDate: start,
MilestoneId: 0, Deadline: deadline,
Priority: 0, Priority: int(TaskPriorityNormal),
// Deadline +2 days MilestoneId: int(*ms.ID),
StartDate: Time(time.Now().AddDate(0, 0, -2)),
Deadline: Time(time.Now().AddDate(0, 0, 2)),
}) })
if err != nil {
t.Fatalf("CreateProjectTask: %v", err)
}
if task.ID == nil {
t.Fatal("created task without id")
}
// Update project task newStart := Time(time.Now().AddDate(0, 0, -14))
startDate := Time(time.Now().AddDate(0, 0, -14)) newDeadline := Time(time.Now().AddDate(0, 0, 3))
deadline := Time(time.Now().AddDate(0, 0, 3)) if _, err := c.UpdateProjectTask(ProjectTaskUpdateRequest{
task, err = client.UpdateProjectTask(ProjectTaskUpdateRequest{
ID: *task.ID, ID: *task.ID,
Title: "Test task updated", Title: "integration parent task (updated)",
Description: "Test task description updated", Description: "updated",
StartDate: &startDate, StartDate: &newStart,
Deadline: &deadline, Deadline: &newDeadline,
}) }); err != nil {
t.Fatalf("UpdateProjectTask: %v", err)
}
// Subtask uses the form-encoded helper; exercises httpx.postForm path.
ctx := context.Background()
sub, err := c.AddSubtask(ctx, strconv.Itoa(*task.ID), "integration subtask")
if err != nil { if err != nil {
t.Errorf("Failed to create task: %v", err) t.Fatalf("AddSubtask: %v", err)
}
if _, ok := sub["id"]; !ok {
t.Errorf("subtask response missing id: %+v", sub)
}
} }
if task == nil { func TestIntegrationCalendarAndCRMRead(t *testing.T) {
t.Error("Value is empty") c := liveClient(t)
ctx := context.Background()
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
if _, err := c.ListCalendars(ctx, start, end); err != nil {
t.Errorf("ListCalendars: %v", err)
}
if _, err := c.ListEvents(ctx, start, end); err != nil {
t.Errorf("ListEvents: %v", err)
}
if _, _, err := c.ListContacts(ctx, 5, 0, ""); err != nil {
t.Errorf("ListContacts: %v", err)
}
if _, _, err := c.ListOpportunities(ctx, 5, 0); err != nil {
t.Errorf("ListOpportunities: %v", err)
}
if _, err := c.ListDealStages(ctx); err != nil {
t.Errorf("ListDealStages: %v", err)
}
} }
// Delete project // TestIntegrationDryRunList covers the tiniest project-tasks read that
prj, err := client.DeleteProject(*project.ID) // inventar-sync depends on; confirms GetTasks deserialises real responses.
if err != nil { func TestIntegrationListTasks(t *testing.T) {
t.Errorf("Failed to delete project: %v", err) c := liveClient(t)
defaults := GetEnvironmentDefaults()
// ProjectID default is "33"; skip the read-only list if the caller hasn't
// pointed us at a valid project (we don't know which projects exist).
if defaults.ProjectID == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not configured")
} }
pid, err := strconv.Atoi(defaults.ProjectID)
if *prj.ID != *project.ID { if err != nil || pid <= 0 {
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID) t.Skipf("ONLYOFFICE_PROJECT_ID %q is not a positive int", defaults.ProjectID)
} }
if _, err := c.GetTasks(NewProjectGetTasksRequest(pid)); err != nil {
// Test list projects t.Fatalf("GetTasks(%d): %v", pid, err)
projects, err = client.GetProjects()
if err != nil {
t.Errorf("Failed to get projects: %v", err)
}
if len(projects) == 0 {
t.Error("Value is empty")
} }
} }
+50
View File
@@ -0,0 +1,50 @@
// Package bootstrap loads CLI environment and constructs an authenticated
// OnlyOffice client. Shared by cmd/oo and cmd/office; the library never
// loads dotfiles.
package bootstrap
import (
"context"
"fmt"
"os"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
)
// LoadEnv reads .env from the current working directory and applies OO_* aliases.
func LoadEnv() {
_ = godotenv.Load(".env")
applyEnvAliases()
}
// NewClient loads env, validates credentials, and authenticates against OnlyOffice.
func NewClient(ctx context.Context) (*onlyoffice.Client, error) {
LoadEnv()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.AuthenticateContext(ctx); err != nil {
return nil, err
}
return c, nil
}
func applyEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
+95
View File
@@ -0,0 +1,95 @@
package bootstrap_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
)
func TestLoadEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.example.com\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.example.com" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestLoadEnvDoesNotOverrideCanonicalWithAlias(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestNewClientReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
_, err = bootstrap.NewClient(context.Background())
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+53
View File
@@ -0,0 +1,53 @@
package fetch
import (
"context"
"fmt"
"time"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// CalendarItemFields maps calendar API rows to list items.
var CalendarItemFields = FieldMap{IDKey: "objectId", TitleKey: "title"}
// CalendarItemsFromRows converts API calendar/event rows into list items.
func CalendarItemsFromRows(rows []map[string]any) []model.Item {
out := make([]model.Item, 0, len(rows))
for _, row := range rows {
kind, typeLabel := model.ClassifyCalendarRow(row)
title := str(row, "title")
if title == "" {
title = str(row, "name")
}
if title == "" {
title = "(untitled)"
}
raw := make(map[string]any, len(row)+1)
for k, v := range row {
raw[k] = v
}
raw["type"] = typeLabel
out = append(out, model.Item{
ID: idStr(row, "objectId"),
Title: title,
Kind: kind,
Raw: raw,
})
}
return out
}
// ListCalendar returns calendars and events for the upcoming date window.
func (l *Loader) ListCalendar(ctx context.Context) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
start := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
end := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
rows, err := l.Client.ListCalendars(ctx, start, end)
if err != nil {
return nil, err
}
return CalendarItemsFromRows(rows), nil
}
+107
View File
@@ -0,0 +1,107 @@
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func skipWithoutLiveAPI(t *testing.T) {
t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
if os.Getenv("ONLYOFFICE_USER") == "" && os.Getenv("ONLYOFFICE_NAME") == "" {
t.Skip("ONLYOFFICE_USER not set")
}
if os.Getenv("ONLYOFFICE_PASS") == "" && os.Getenv("ONLYOFFICE_PASSWORD") == "" {
t.Skip("ONLYOFFICE_PASS not set")
}
}
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
t.Helper()
skipWithoutLiveAPI(t)
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
return &fetch.Loader{Client: client}, context.Background()
}
func TestIntegrationListAllSubjects(t *testing.T) {
loader, ctx := liveLoader(t)
cases := []model.ListSpec{
{Subject: model.SubjectProjects},
{Subject: model.SubjectTasks},
{Subject: model.SubjectCalendar},
{Subject: model.SubjectContacts},
{Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies},
{Subject: model.SubjectOpportunities},
{Subject: model.SubjectCases},
{Subject: model.SubjectCRMTasks},
{Subject: model.SubjectMailInbox},
{Subject: model.SubjectUsers},
}
for _, spec := range cases {
t.Run(string(spec.Subject), func(t *testing.T) {
items, err := loader.List(ctx, spec)
if err != nil {
t.Fatalf("List(%s): %v", spec.Subject, err)
}
t.Logf("%s: %d items", spec.Subject, len(items))
for i, it := range items {
if it.ID == "" {
t.Errorf("item[%d] missing ID", i)
}
if it.Title == "" {
t.Errorf("item[%d] missing Title", i)
}
}
})
}
}
func TestIntegrationListProjectsMapsRealFields(t *testing.T) {
loader, ctx := liveLoader(t)
spec := model.ListSpec{Subject: model.SubjectProjects}
items, err := loader.List(ctx, spec)
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects on instance")
}
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("Detail: %v", err)
}
if detail == nil {
t.Fatal("nil detail")
}
}
func TestIntegrationMailInboxDetail(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("inbox empty")
}
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("GetMailMessage: %v", err)
}
if detail["subject"] == nil {
t.Fatalf("message missing subject: %+v", detail)
}
}
+387
View File
@@ -0,0 +1,387 @@
package fetch
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// MailListPageSize is how many messages each infinite-scroll fetch loads.
const MailListPageSize = 25
// Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct {
Client *onlyoffice.Client
}
// List returns items for the given list spec (nav leaf).
func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
switch spec.Subject {
case model.SubjectProjects:
return l.listProjects(ctx)
case model.SubjectTasks:
if spec.ProjectID != "" {
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx)
case model.SubjectCalendar:
return l.ListCalendar(ctx)
case model.SubjectCalendars:
return l.ListCalendar(ctx)
case model.SubjectEvents:
return l.ListCalendar(ctx)
case model.SubjectContacts:
return l.listContacts(ctx, nil)
case model.SubjectPersons:
falseVal := false
return l.listContacts(ctx, &falseVal)
case model.SubjectCompanies:
trueVal := true
return l.listContacts(ctx, &trueVal)
case model.SubjectOpportunities:
return l.listOpportunities(ctx)
case model.SubjectCases:
return l.listCases(ctx)
case model.SubjectCRMTasks:
return l.listCRMTasks(ctx)
case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
case model.SubjectUsers:
return l.listUsers(ctx)
case model.SubjectProjectFiles:
pid := spec.ProjectID
if pid == "" {
pid = onlyoffice.GetEnvironmentDefaults().ProjectID
}
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
}
return l.listProjectFiles(ctx, pid)
case model.SubjectTaskFiles:
if spec.TaskID == "" {
return nil, fmt.Errorf("pick a task under Projects in the tree")
}
return l.listTaskFiles(ctx, spec.TaskID)
default:
return nil, fmt.Errorf("unsupported subject %q", spec.Subject)
}
}
// LoadProjectsForNav returns projects to inject as dynamic tree nodes.
func (l *Loader) LoadProjectsForNav(ctx context.Context) ([]model.Item, error) {
return l.listProjects(ctx)
}
// Detail fetches full record data for preview when list row is insufficient.
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
switch item.Kind {
case model.KindOpportunity:
return l.Client.GetOpportunity(ctx, item.ID)
case model.KindContact:
return l.Client.GetContact(ctx, item.ID)
case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask, model.KindCRMTask:
return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID)
case model.KindUser:
return l.Client.GetUser(ctx, item.ID)
default:
if item.Raw != nil {
return item.Raw, nil
}
return map[string]any{"id": item.ID, "title": item.Title}, nil
}
}
// Execute runs a user-selected action on an item.
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
switch action {
case model.ActionView:
return "view", nil
case model.ActionDelete:
return l.executeDelete(ctx, item)
case model.ActionDownload:
return l.executeDownload(ctx, item, destPath)
default:
return "", fmt.Errorf("unsupported action %q", action)
}
}
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
switch item.Kind {
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.DeleteProject(id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted project %s", item.Title), nil
case model.KindTask:
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted task %s", item.Title), nil
case model.KindContact:
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted contact %s", item.Title), nil
case model.KindOpportunity:
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted deal %s", item.Title), nil
case model.KindCase:
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted case %s", item.Title), nil
case model.KindCRMTask:
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
case model.KindMail:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted message %s", item.Title), nil
case model.KindFile:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
return "", err
}
return fmt.Sprintf("Deleted file %s", item.Title), nil
default:
return "", fmt.Errorf("delete not supported for %s", item.Kind)
}
}
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
if item.Kind != model.KindFile {
return "", fmt.Errorf("download only for files")
}
if destPath == "" {
destPath = filepath.Join(os.TempDir(), "office", item.Title)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", err
}
f, err := os.Create(destPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
return "", err
}
return fmt.Sprintf("Downloaded to %s", destPath), nil
}
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
rows, err := l.Client.ListTasks(ctx, projectID, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
files, err := l.Client.GetTaskFiles(ctx, taskID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
projects, err := l.Client.GetProjects()
if err != nil {
return nil, err
}
items := make([]model.Item, len(projects))
for i, p := range projects {
id := ""
if p.ID != nil {
id = fmt.Sprint(*p.ID)
}
title := ""
if p.Title != nil {
title = *p.Title
}
raw := map[string]any{
"id": id,
"title": title,
}
if p.TaskCount != nil {
raw["taskCount"] = *p.TaskCount
}
if p.TaskCountTotal != nil {
raw["taskCountTotal"] = *p.TaskCountTotal
}
if p.DocumentsCount != nil {
raw["documentsCount"] = *p.DocumentsCount
}
if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount
}
if p.Status != nil {
raw["status"] = *p.Status
}
items[i] = model.Item{
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
}
}
return items, nil
}
func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllTasks(ctx, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx)
if err != nil {
return nil, err
}
if companyOnly != nil {
filtered := make([]map[string]any, 0, len(rows))
for _, r := range rows {
isCo, _ := r["isCompany"].(bool)
if isCo == *companyOnly {
filtered = append(filtered, r)
}
}
rows = filtered
}
return ItemsFromMaps(rows, model.KindContact, ContactItemFields), nil
}
func (l *Loader) listOpportunities(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllOpportunities(ctx)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindOpportunity, FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "stage"}), nil
}
func (l *Loader) listCases(ctx context.Context) ([]model.Item, error) {
cases, _, err := l.Client.ListCases(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(cases, model.KindCase, FieldMap{IDKey: "id", TitleKey: "title"}), nil
}
func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
rows, _, err := l.Client.ListCRMTasks(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
}
func (l *Loader) listMail(ctx context.Context, folder int, startIndex int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{
Folder: folder,
Count: MailListPageSize,
StartIndex: startIndex,
})
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
}
// ListMailMore loads the next page of messages for an active mail folder list.
func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) {
folder, ok := mailFolderForSubject(spec.Subject)
if !ok {
return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject)
}
return l.listMail(ctx, folder, startIndex)
}
func mailFolderForSubject(subject model.Subject) (int, bool) {
switch subject {
case model.SubjectMailInbox:
return onlyoffice.MailFolderInbox, true
case model.SubjectMailSent:
return onlyoffice.MailFolderSent, true
case model.SubjectMailDrafts:
return onlyoffice.MailFolderDrafts, true
case model.SubjectMailTrash:
return onlyoffice.MailFolderTrash, true
case model.SubjectMailSpam:
return onlyoffice.MailFolderSpam, true
default:
return 0, false
}
}
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
resp, err := l.Client.GetProjectFiles(ctx, projectID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range resp.Files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
+78
View File
@@ -0,0 +1,78 @@
package fetch
import (
"fmt"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// FieldMap names the JSON keys used when building list items from API rows.
type FieldMap struct {
IDKey string
TitleKey string
SubtitleKey string
}
// TaskItemFields is the default field map for project tasks.
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"}
// ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
// ContactItemFields is the default field map for CRM contacts.
var ContactItemFields = FieldMap{IDKey: "id", TitleKey: "displayName", SubtitleKey: "primaryEmail"}
// MailItemFields is the default field map for mail messages.
var MailItemFields = FieldMap{IDKey: "id", TitleKey: "subject", SubtitleKey: "from"}
// ItemsFromMaps converts OnlyOffice list rows into TUI items.
func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []model.Item {
out := make([]model.Item, len(rows))
for i, row := range rows {
title := str(row, fields.TitleKey)
if title == "" {
title = str(row, "title")
}
if title == "" {
title = str(row, "name")
}
if title == "" {
title = "(untitled)"
}
raw := row
out[i] = model.Item{
ID: idStr(row, fields.IDKey),
Title: title,
Subtitle: str(row, fields.SubtitleKey),
Kind: kind,
Raw: raw,
}
}
return out
}
func idStr(m map[string]any, key string) string {
if key == "" {
key = "id"
}
switch v := m[key].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprint(m[key])
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
+43
View File
@@ -0,0 +1,43 @@
package fetch
import (
"bytes"
"context"
"fmt"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
// PreviewMarkdown loads item data and returns markdown for the preview pane.
func (l *Loader) PreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if l == nil || l.Client == nil {
return "", fmt.Errorf("fetch: client is nil")
}
if item.Kind == model.KindFile {
return l.filePreviewMarkdown(ctx, item)
}
raw, err := l.Detail(ctx, item)
if err != nil {
return "", err
}
return preview.EntityMarkdown(string(item.Kind), raw), nil
}
func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if item.ID == "" {
return "", fmt.Errorf("file id missing")
}
name := item.Title
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
if t := onlyoffice.FileEntryTitle(meta); t != "" {
name = t
}
}
var buf bytes.Buffer
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
return "", err
}
return preview.FileBytesToMarkdown(name, buf.Bytes())
}
+74
View File
@@ -0,0 +1,74 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// SaveItem persists editable form fields for supported entity kinds.
func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.FormFields) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
switch item.Kind {
case model.KindTask:
return l.UpdateTask(ctx, item.ID, fields)
case model.KindProject:
return l.saveProject(ctx, item.ID, fields)
case model.KindUser:
return l.SaveUser(ctx, item.ID, fields)
default:
return fmt.Errorf("save not supported for %s", item.Kind)
}
}
func (l *Loader) saveProject(ctx context.Context, projectID string, fields model.FormFields) error {
id, err := strconv.Atoi(projectID)
if err != nil {
return err
}
if fields.ResponsibleID == "" {
raw, derr := l.Detail(ctx, model.Item{ID: projectID, Kind: model.KindProject})
if derr == nil {
fields.ResponsibleID = model.ResponsibleIDFromRaw(raw)
}
}
req := onlyoffice.ProjectUpdateRequest{
ID: id,
Title: fields.Primary,
Description: fields.Secondary,
}
if fields.ResponsibleID != "" {
req.ResponsibleID = fields.ResponsibleID
}
if _, err := l.Client.UpdateProject(req); err != nil {
return err
}
if fields.HasStatus {
if _, err := l.Client.UpdateProjectStatus(id, string(fields.Status)); err != nil {
return err
}
}
return nil
}
// DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item)
if err != nil {
return model.FormFields{}, err
}
fields := model.FormFieldsFromRaw(item.Kind, raw)
if item.Kind == model.KindTask {
choices, uerr := l.LoadUserChoices(ctx)
if uerr != nil {
return model.FormFields{}, uerr
}
fields.UserChoices = choices
}
return fields, nil
}
+52
View File
@@ -0,0 +1,52 @@
//go:build integration
package fetch_test
import (
"testing"
"time"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationSaveProject(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects")
}
item := items[0]
fields, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatalf("DetailForm: %v", err)
}
if fields.ResponsibleID == "" {
t.Skip("project has no responsible id on this instance")
}
marker := " office-save-test " + time.Now().UTC().Format(time.RFC3339)
fields.Primary = fields.Primary + marker
if err := loader.SaveItem(ctx, item, fields); err != nil {
t.Fatalf("SaveItem: %v", err)
}
after, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatalf("DetailForm after save: %v", err)
}
if after.Primary != fields.Primary {
t.Fatalf("title not saved: got %q want %q", after.Primary, fields.Primary)
}
fields.Primary = stringsTrimSuffixMarker(after.Primary, marker)
if err := loader.SaveItem(ctx, item, fields); err != nil {
t.Logf("cleanup save: %v", err)
}
}
func stringsTrimSuffixMarker(s, marker string) string {
if len(s) >= len(marker) && s[len(s)-len(marker):] == marker {
return s[:len(s)-len(marker)]
}
return s
}
+53
View File
@@ -0,0 +1,53 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// UpdateTask saves editable task fields via the typed JSON API.
func (l *Loader) UpdateTask(ctx context.Context, taskID string, fields model.FormFields) error {
return l.updateTask(ctx, taskID, fields, false)
}
// CloseTask saves fields and sets status to closed.
func (l *Loader) CloseTask(ctx context.Context, taskID string, fields model.FormFields) error {
fields.TaskStatus = model.TaskLifecycleClosed
return l.updateTask(ctx, taskID, fields, true)
}
func (l *Loader) updateTask(ctx context.Context, taskID string, fields model.FormFields, closing bool) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
id, err := strconv.Atoi(taskID)
if err != nil {
return fmt.Errorf("task id %q: %w", taskID, err)
}
status := onlyoffice.ProjectTaskStatus(fields.TaskStatus)
if closing {
status = onlyoffice.ProjectTaskStatusClosed
}
req := onlyoffice.ProjectTaskUpdateRequest{
ID: id,
Title: fields.Primary,
Description: fields.Secondary,
Status: status,
}
if fields.ResponsibleID != "" {
req.Responsible = []string{fields.ResponsibleID}
} else if !closing {
raw, derr := l.Detail(ctx, model.Item{ID: taskID, Kind: model.KindTask})
if derr == nil {
if rid := model.TaskResponsibleIDFromRaw(raw); rid != "" {
req.Responsible = []string{rid}
}
}
}
_, err = l.Client.UpdateProjectTask(req)
return err
}
+68
View File
@@ -0,0 +1,68 @@
//go:build integration
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
item := items[0]
fields, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatal(err)
}
restore := fields
t.Cleanup(func() {
_ = loader.UpdateTask(context.Background(), item.ID, restore)
})
fields.Primary = fields.Primary + " (office TUI test)"
fields.Secondary = fields.Secondary + "\n\n_edited by office integration test_"
if err := loader.UpdateTask(ctx, item.ID, fields); err != nil {
t.Fatal(err)
}
got, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatal(err)
}
if got.Primary != fields.Primary {
t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
}
if got.Secondary != fields.Secondary {
t.Fatalf("description mismatch")
}
}
func TestIntegrationTaskFieldsFromLiveAPI(t *testing.T) {
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
title, desc, err := loader.TaskFields(ctx, items[0])
if err != nil {
t.Fatal(err)
}
if title == "" {
t.Fatal("empty title")
}
_ = desc
}
+131
View File
@@ -0,0 +1,131 @@
package fetch
import (
"context"
"encoding/json"
"fmt"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
users, err := l.Client.GetUsers()
if err != nil {
return nil, err
}
items := make([]model.Item, 0, len(users))
for _, u := range users {
if u == nil {
continue
}
raw := userToRaw(u)
id := strMap(raw, "id")
userName := strMap(raw, "userName")
if userName == "" {
userName = strMap(raw, "email")
}
items = append(items, model.Item{
ID: id,
Title: userName,
Kind: model.KindUser,
Raw: raw,
})
}
return items, nil
}
func userToRaw(u *onlyoffice.User) map[string]any {
b, err := json.Marshal(u)
if err != nil {
return map[string]any{}
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
return map[string]any{}
}
return raw
}
func strMap(m map[string]any, key string) string {
if m == nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
func userProfileUpdateBody(isAdmin bool, modules []string) map[string]any {
body := map[string]any{
"isAdmin": isAdmin,
}
if !isAdmin {
body["listAdminModules"] = modules
}
return body
}
// SaveUser persists user account settings from the detail form.
func (l *Loader) SaveUser(ctx context.Context, userID string, fields model.FormFields) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
raw, err := l.Client.GetUser(ctx, userID)
if err != nil {
return err
}
wasEnabled := model.UserIsEnabled(raw)
if fields.UserEnabled && !wasEnabled {
if err := l.Client.ChangeUserStatus(ctx, userID, true); err != nil {
return err
}
}
isAdmin, modules := fields.UserACL.APIPayload()
body := userProfileUpdateBody(isAdmin, modules)
if _, err := l.updateUserProfile(ctx, userID, body, fields.UserEnabled); err != nil {
return err
}
if !fields.UserEnabled && wasEnabled {
if err := l.Client.ChangeUserStatus(ctx, userID, false); err != nil {
return err
}
}
if fields.UserPassword != "" {
if err := l.Client.ChangeUserPassword(ctx, userID, fields.UserPassword); err != nil {
return err
}
}
return nil
}
func (l *Loader) updateUserProfile(ctx context.Context, userID string, body map[string]any, wantEnabled bool) (map[string]any, error) {
out, err := l.Client.UpdateUser(ctx, userID, body)
if err == nil {
return out, nil
}
if wantEnabled && isSuspendedUserError(err) {
if actErr := l.Client.ChangeUserStatus(ctx, userID, true); actErr != nil {
return nil, err
}
return l.Client.UpdateUser(ctx, userID, body)
}
return nil, err
}
func isSuspendedUserError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "suspended") || strings.Contains(msg, "terminated")
}
+34
View File
@@ -0,0 +1,34 @@
package fetch
import (
"fmt"
"testing"
)
func TestUserProfileUpdateBodyOmitsStatus(t *testing.T) {
body := userProfileUpdateBody(true, nil)
if _, ok := body["status"]; ok {
t.Fatal("profile update must not include status")
}
if body["isAdmin"] != true {
t.Fatal("expected isAdmin true")
}
}
func TestUserProfileUpdateBodyPartialAdminModules(t *testing.T) {
body := userProfileUpdateBody(false, []string{"documents", "crm"})
if body["isAdmin"] != false {
t.Fatal("expected partial admin")
}
mods, ok := body["listAdminModules"].([]string)
if !ok || len(mods) != 2 {
t.Fatalf("modules=%v", body["listAdminModules"])
}
}
func TestIsSuspendedUserError(t *testing.T) {
err := fmt.Errorf(`PUT JSON /api/2.0/people/x: 500 {"error":{"message":"The user is suspended"}}`)
if !isSuspendedUserError(err) {
t.Fatal("expected suspended detection")
}
}
+33
View File
@@ -0,0 +1,33 @@
package fetch
import (
"context"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// LoadUserChoices returns portal users for responsible pickers.
func (l *Loader) LoadUserChoices(ctx context.Context) ([]model.UserOption, error) {
if l == nil || l.Client == nil {
return nil, nil
}
users, err := l.Client.GetUsers()
if err != nil {
return nil, err
}
out := make([]model.UserOption, 0, len(users))
for _, u := range users {
if u == nil || u.ID == nil || *u.ID == "" {
continue
}
name := ""
if u.DisplayName != nil {
name = *u.DisplayName
}
if name == "" && u.Email != nil {
name = *u.Email
}
out = append(out, model.UserOption{ID: *u.ID, Name: name})
}
return out, nil
}
+34
View File
@@ -0,0 +1,34 @@
// Command office is a terminal UI for OnlyOffice Workspace.
//
// Three-pane layout: module tree (left), selectable list (center),
// markdown preview (right). Uses github.com/eslider/go-onlyoffice for API calls.
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/office@latest
package main
import (
"context"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func main() {
client, err := bootstrap.NewClient(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := ui.NewModel(client)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+69
View File
@@ -0,0 +1,69 @@
package model
// ActionID identifies an operation on a list item.
type ActionID string
const (
ActionView ActionID = "view"
ActionSave ActionID = "save"
ActionClose ActionID = "close"
ActionDelete ActionID = "delete"
ActionRefresh ActionID = "refresh"
ActionDownload ActionID = "download"
)
// ItemAction is one CRUD-style operation offered for an item.
type ItemAction struct {
ID ActionID
Label string
Danger bool
}
// ActionsFor returns available operations for an item kind (detail pane action bar).
func ActionsFor(kind Kind) []ItemAction {
switch kind {
case KindProject:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete", Danger: true},
}
case KindTask:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
{ID: ActionClose, Label: "Close"},
}
case KindCRMTask:
return []ItemAction{
{ID: ActionDelete, Label: "Delete", Danger: true},
}
case KindContact:
return []ItemAction{
{ID: ActionDelete, Label: "Delete contact", Danger: true},
}
case KindOpportunity:
return []ItemAction{
{ID: ActionDelete, Label: "Delete deal", Danger: true},
}
case KindCase:
return []ItemAction{
{ID: ActionDelete, Label: "Delete case", Danger: true},
}
case KindMail:
return []ItemAction{
{ID: ActionDelete, Label: "Delete message", Danger: true},
}
case KindFile:
return []ItemAction{
{ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true},
}
case KindUser:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
}
case KindEvent, KindCalendar:
return nil
default:
return nil
}
}
+40
View File
@@ -0,0 +1,40 @@
package model
import "fmt"
// ClassifyCalendarRow decides whether an API row is a calendar or an event.
func ClassifyCalendarRow(raw map[string]any) (Kind, string) {
if raw == nil {
return KindCalendar, "Calendar"
}
if strRaw(raw, "start") != "" || strRaw(raw, "end") != "" {
return KindEvent, "Event"
}
if strRaw(raw, "eventType") != "" {
return KindEvent, "Event"
}
return KindCalendar, "Calendar"
}
// CalendarTypeLabel returns the type column text for a calendar list row.
func CalendarTypeLabel(it Item) string {
if it.Raw != nil {
if t := strRaw(it.Raw, "type"); t != "" {
return t
}
}
_, label := ClassifyCalendarRow(it.Raw)
return label
}
// FormatCalendarDateTime formats start/end timestamps for the calendar table.
func FormatCalendarDateTime(v any) string {
t, ok := parseDeadlineTime(v)
if !ok {
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
return ""
}
return t.Format("Jan 2 15:04")
}
+37
View File
@@ -0,0 +1,37 @@
package model
import "testing"
func TestClassifyCalendarRow(t *testing.T) {
kind, label := ClassifyCalendarRow(map[string]any{
"title": "Standup",
"start": "2026-06-24T10:00:00Z",
})
if kind != KindEvent || label != "Event" {
t.Fatalf("got kind=%s label=%q", kind, label)
}
kind, label = ClassifyCalendarRow(map[string]any{"title": "Work"})
if kind != KindCalendar || label != "Calendar" {
t.Fatalf("got kind=%s label=%q", kind, label)
}
}
func TestBuildCalendarColumnsIncludesType(t *testing.T) {
items := []Item{{
ID: "1", Title: "Meet", Kind: KindEvent,
Raw: map[string]any{"type": "Event", "start": "2026-06-24T10:00:00Z"},
}}
cols := BuildColumns(SubjectCalendar, items)
found := false
for _, c := range cols {
if c.Key == "type" {
found = true
}
if c.Key == "subtitle" {
t.Fatal("subtitle column should not appear")
}
}
if !found {
t.Fatal("expected type column")
}
}
+400
View File
@@ -0,0 +1,400 @@
package model
import (
"fmt"
"sort"
"strconv"
"strings"
)
// TableColumn is one column in the center data table.
type TableColumn struct {
Key string
Title string
Width int
}
var subjectExtraKeys = map[Subject][]string{
SubjectTasks: {"status", "deadline", "responsible"},
SubjectCalendars: {"description"},
SubjectEvents: {"start", "end"},
SubjectContacts: {"primaryEmail", "displayName"},
SubjectPersons: {"primaryEmail", "displayName"},
SubjectCompanies: {"primaryEmail", "displayName"},
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
SubjectCases: {"status"},
SubjectCRMTasks: {"status", "deadline"},
SubjectMailInbox: {"from", "date"},
SubjectMailSent: {"to", "date"},
SubjectMailDrafts: {"to", "date"},
SubjectMailTrash: {"from", "date"},
SubjectMailSpam: {"from", "date"},
}
// BuildColumns derives table columns from the list subject and item payloads.
func BuildColumns(subject Subject, items []Item) []TableColumn {
if subject == SubjectProjects {
return buildProjectColumns(items)
}
if subject == SubjectUsers {
return buildUserColumns(items)
}
if subject == SubjectTasks {
return buildTaskColumns(items)
}
if subject == SubjectCalendar {
return buildCalendarColumns(items)
}
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
if hasSubtitle(items) {
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
}
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
for _, key := range subjectExtraKeys[subject] {
if seen[key] || !columnHasData(items, key) {
continue
}
seen[key] = true
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
discovered := discoverRawKeys(items, seen, 8)
for _, key := range discovered {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
sizeColumns(cols, items)
return cols
}
func buildCalendarColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "type", Title: "Type", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
for _, key := range []string{"start", "end"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildTaskColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "status", Title: "Status", Width: 10},
}
for _, key := range []string{"deadline", "responsible"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildUserColumns(items []Item) []TableColumn {
_ = items
return []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "userName", Title: "User", Width: 14},
{Key: "registration", Title: "Registered", Width: 12},
{Key: "status", Title: "Status", Width: 10},
{Key: "email", Title: "Email", Width: 20},
}
}
func buildProjectColumns(items []Item) []TableColumn {
_ = items
return []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "●", Width: 10},
{Key: "title", Title: "Title", Width: 16},
{Key: "tasks", Title: "Tasks", Width: 11},
{Key: "documents", Title: "Docs", Width: 7},
{Key: "users", Title: "Users", Width: 7},
}
}
// CellText returns the display string for one table cell.
func CellText(it Item, key string) string {
switch key {
case "_sel":
if it.Selected {
return "●"
}
return "○"
case "id":
return it.ID
case "title":
return it.Title
case "type":
return CalendarTypeLabel(it)
case "start", "end":
if it.Raw == nil {
return ""
}
if formatted := FormatCalendarDateTime(it.Raw[key]); formatted != "" {
return formatted
}
return formatAny(it.Raw[key])
case "status":
switch it.Kind {
case KindProject:
return ProjectStatusCell(it.Raw)
case KindUser:
return UserStatusLabel(it.Raw)
case KindTask, KindCRMTask:
if it.Raw == nil {
return ""
}
return TaskStatusLabel(it.Raw["status"])
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["status"])
}
case "deadline":
if it.Raw == nil {
return ""
}
// CRM tasks use camelCase deadLine; project tasks use deadline.
rawDL := it.Raw["deadline"]
if rawDL == nil || formatAny(rawDL) == "" {
rawDL = it.Raw["deadLine"]
}
if rel := FormatRelativeDeadline(rawDL); rel != "" {
return rel
}
return formatAny(rawDL)
case "responsible":
if it.Kind == KindTask || it.Kind == KindCRMTask {
return TaskResponsibleLabel(it.Raw)
}
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["responsible"])
case "subtitle":
return it.Subtitle
case "userName":
if it.Raw != nil {
if u := strRaw(it.Raw, "userName"); u != "" {
return u
}
}
return it.Title
case "registration":
return FormatUserRegistration(it.Raw)
case "email":
if it.Raw != nil {
return strRaw(it.Raw, "email")
}
return it.Subtitle
case "tasks":
return formatProjectTasks(it.Raw)
case "documents":
return intRaw(it.Raw, "documentsCount")
case "users":
return intRaw(it.Raw, "participantCount")
case "kind":
return string(it.Kind)
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw[key])
}
}
func formatProjectTasks(raw map[string]any) string {
if raw == nil {
return "0/0"
}
open := intRawVal(raw, "taskCount")
total := intRawVal(raw, "taskCountTotal")
closed := total - open
if closed < 0 {
closed = 0
}
return fmt.Sprintf("%d/%d", open, closed)
}
func intRaw(raw map[string]any, key string) string {
if raw == nil {
return ""
}
v := intRawVal(raw, key)
if v == 0 {
if _, ok := raw[key]; !ok {
return ""
}
}
return fmt.Sprintf("%d", v)
}
func intRawVal(raw map[string]any, key string) int {
if raw == nil {
return 0
}
switch v := raw[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
default:
if raw[key] == nil {
return 0
}
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
return n
}
}
func hasSubtitle(items []Item) bool {
for _, it := range items {
if it.Subtitle != "" {
return true
}
}
return false
}
func columnHasData(items []Item, key string) bool {
for _, it := range items {
if CellText(it, key) != "" {
return true
}
}
return false
}
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
counts := map[string]int{}
for _, it := range items {
if it.Raw == nil {
continue
}
for k := range it.Raw {
if seen[k] || k == "id" || k == "title" {
continue
}
counts[k]++
}
}
type kv struct {
k string
n int
}
var ranked []kv
for k, n := range counts {
ranked = append(ranked, kv{k, n})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].n == ranked[j].n {
return ranked[i].k < ranked[j].k
}
return ranked[i].n > ranked[j].n
})
out := make([]string, 0, limit)
for _, r := range ranked {
if len(out) >= limit {
break
}
out = append(out, r.k)
}
return out
}
func titleLabel(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
func defaultWidth(key string) int {
switch key {
case "id", "status", "stage":
return 10
case "start", "end", "deadline", "date":
return 20
case "from", "to", "email", "primaryEmail":
return 22
case "description", "displayName":
return 24
default:
return 14
}
}
func sizeColumns(cols []TableColumn, items []Item) {
for i := range cols {
maxW := runeLen(cols[i].Title)
for _, it := range items {
n := runeLen(CellText(it, cols[i].Key))
if n > maxW {
maxW = n
}
}
maxW += 2
if maxW < 4 {
maxW = 4
}
if maxW > 36 {
maxW = 36
}
cols[i].Width = maxW
}
}
func runeLen(s string) int {
return len([]rune(s))
}
func formatAny(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case bool:
if x {
return "yes"
}
return "no"
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%g", x)
default:
return fmt.Sprint(v)
}
}
+79
View File
@@ -0,0 +1,79 @@
package model
import "testing"
func TestBuildProjectColumns(t *testing.T) {
items := []Item{{
ID: "1", Title: "Alpha", Kind: KindProject,
Raw: map[string]any{
"taskCount": 3, "taskCountTotal": 5,
"documentsCount": 2, "participantCount": 4,
},
}}
cols := BuildColumns(SubjectProjects, items)
want := []string{"_sel", "id", "status", "title", "tasks", "documents", "users"}
if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", len(cols), len(want))
}
for i, key := range want {
if cols[i].Key != key {
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
}
}
for _, c := range cols {
if c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key)
}
}
}
func TestCellTextProjectStatus(t *testing.T) {
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
if got := CellText(open, "status"); got != "🟢 Open" {
t.Fatalf("got %q", got)
}
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
if got := CellText(closed, "status"); got != "🔴 Closed" {
t.Fatalf("got %q", got)
}
paused := Item{Kind: KindProject, Raw: map[string]any{"status": 1}}
if got := CellText(paused, "status"); got != "🟡 Paused" {
t.Fatalf("got %q", got)
}
}
func TestBuildProjectColumnsStatusHeaderIsIcon(t *testing.T) {
cols := BuildColumns(SubjectProjects, nil)
for _, c := range cols {
if c.Key == "status" && c.Title != "●" {
t.Fatalf("status header=%q want ●", c.Title)
}
}
}
func TestFormatProjectTasksOpenClosed(t *testing.T) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" {
t.Fatalf("got %q want 3/5", got)
}
}
func TestCellTextProjectDocumentsUsers(t *testing.T) {
it := Item{Raw: map[string]any{"documentsCount": 7, "participantCount": 2}}
if got := CellText(it, "documents"); got != "7" {
t.Fatalf("documents: %q", got)
}
if got := CellText(it, "users"); got != "2" {
t.Fatalf("users: %q", got)
}
}
func TestBuildColumnsIncludesSelectionAndCoreFields(t *testing.T) {
items := []Item{
{ID: "1", Title: "Alpha", Kind: KindTask, Raw: map[string]any{"status": "Open"}},
}
cols := BuildColumns(SubjectTasks, items)
if cols[0].Key != "_sel" {
t.Fatalf("first column should be selection, got %q", cols[0].Key)
}
}
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"strings"
)
// FilterItems returns items whose searchable text contains query (case-insensitive).
func FilterItems(items []Item, query string) []Item {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
out := make([]Item, len(items))
copy(out, items)
return out
}
out := make([]Item, 0, len(items))
for _, it := range items {
if strings.Contains(strings.ToLower(ItemSearchText(it)), q) {
out = append(out, it)
}
}
return out
}
// ItemSearchText joins item fields used for list filtering.
func ItemSearchText(it Item) string {
var parts []string
parts = append(parts, it.ID, it.Title, it.Subtitle, string(it.Kind))
if it.Raw != nil {
for _, v := range it.Raw {
parts = append(parts, formatAny(v))
}
}
return strings.Join(parts, " ")
}
+26
View File
@@ -0,0 +1,26 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestFilterItemsByTitle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "Alpha task"},
{ID: "2", Title: "Beta task"},
}
got := model.FilterItems(items, "alpha")
if len(got) != 1 || got[0].ID != "1" {
t.Fatalf("got %#v", got)
}
}
func TestFilterItemsEmptyQueryReturnsAll(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A"}, {ID: "2", Title: "B"}}
got := model.FilterItems(items, " ")
if len(got) != 2 {
t.Fatalf("got len=%d want 2", len(got))
}
}
+233
View File
@@ -0,0 +1,233 @@
package model
import "fmt"
// FormFields holds editable/display fields loaded from API detail.
type FormFields struct {
PrimaryLabel string
SecondaryLabel string
Primary string
Secondary string
ReadOnly bool
HasStatus bool
Status ProjectLifecycle
HasTaskStatus bool
TaskStatus TaskLifecycle
HasResponsible bool
ResponsibleID string
UserChoices []UserOption
ProjectTitle string
TimingSummary string
HasUserEdit bool
UserEnabled bool
UserACL UserACLState
GroupsText string
UserPassword string
}
// KindHeading returns a short label for the detail pane header.
func KindHeading(kind Kind, id string) string {
return fmt.Sprintf("%s %s", kindLabel(kind), id)
}
func kindLabel(kind Kind) string {
switch kind {
case KindProject:
return "Project"
case KindTask:
return "Task"
case KindCRMTask:
return "CRM task"
case KindContact:
return "Contact"
case KindOpportunity:
return "Deal"
case KindCase:
return "Case"
case KindMail:
return "Mail"
case KindEvent:
return "Event"
case KindCalendar:
return "Calendar"
case KindFile:
return "Document"
case KindUser:
return "User"
default:
return string(kind)
}
}
// FormFieldsFromRaw maps API detail JSON to form fields for the right pane.
func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
switch kind {
case KindMail:
body := strRaw(raw, "body")
if body == "" {
body = strRaw(raw, "htmlBody")
}
return FormFields{
PrimaryLabel: "Subject", SecondaryLabel: "Body",
Primary: strRaw(raw, "subject"), Secondary: body,
ReadOnly: true,
}
case KindUser:
acl := UserACLFromRaw(raw)
return FormFields{
HasUserEdit: true,
ReadOnly: false,
UserEnabled: UserIsEnabled(raw),
UserACL: acl,
GroupsText: UserGroupsText(raw),
}
case KindContact:
name := strRaw(raw, "displayName")
if name == "" {
name = strRaw(raw, "title")
}
return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "About",
Primary: name, Secondary: strRaw(raw, "about"),
ReadOnly: true,
}
case KindEvent, KindCalendar:
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
ReadOnly: true,
}
case KindProject:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
return FormFields{
PrimaryLabel: "Title",
SecondaryLabel: "Description",
Primary: title,
Secondary: strRaw(raw, "description"),
ReadOnly: false,
HasStatus: true,
Status: ProjectStatusFromAny(raw["status"]),
ResponsibleID: ResponsibleIDFromRaw(raw),
}
case KindTask:
return FormFields{
PrimaryLabel: "Title",
SecondaryLabel: "Description",
Primary: strRaw(raw, "title"),
Secondary: strRaw(raw, "description"),
ReadOnly: false,
HasTaskStatus: true,
TaskStatus: TaskStatusFromAny(raw["status"]),
HasResponsible: true,
ResponsibleID: TaskResponsibleIDFromRaw(raw),
ProjectTitle: TaskProjectTitle(raw),
TimingSummary: TaskTimingSummary(raw),
}
default:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
if title == "" {
title = strRaw(raw, "subject")
}
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: title, Secondary: strRaw(raw, "description"),
ReadOnly: true,
}
}
}
// TaskResponsibleIDFromRaw returns the first assignee user id from task detail.
func TaskResponsibleIDFromRaw(raw map[string]any) string {
if raw == nil {
return ""
}
if ids, ok := raw["responsibleIds"].([]any); ok && len(ids) > 0 {
return strRaw(map[string]any{"id": ids[0]}, "id")
}
if list, ok := raw["responsibles"].([]any); ok && len(list) > 0 {
if m, ok := list[0].(map[string]any); ok {
return strRaw(m, "id")
}
}
return ""
}
// TaskProjectTitle returns the owning project title when present.
func TaskProjectTitle(raw map[string]any) string {
if raw == nil {
return ""
}
if po, ok := raw["projectOwner"].(map[string]any); ok {
if t := strRaw(po, "title"); t != "" {
return t
}
}
return strRaw(raw, "projectTitle")
}
// TaskTimingSummary formats start and deadline for the detail form.
func TaskTimingSummary(raw map[string]any) string {
if raw == nil {
return ""
}
start := formatTaskDateLabel(raw["startDate"])
deadline := formatTaskDateLabel(raw["deadline"])
switch {
case start != "" && deadline != "":
return start + " → " + deadline
case deadline != "":
return "Due " + deadline
case start != "":
return "From " + start
default:
return ""
}
}
func formatTaskDateLabel(v any) string {
t, ok := parseDeadlineTime(v)
if !ok {
s := strRaw(map[string]any{"v": v}, "v")
if s == "" || s == "<nil>" {
return ""
}
return s
}
return t.Format("2006-01-02 15:04")
}
func strRaw(m map[string]any, key string) string {
if m == nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
// ResponsibleIDFromRaw extracts the project responsible user id from API detail.
func ResponsibleIDFromRaw(raw map[string]any) string {
if id := strRaw(raw, "responsibleId"); id != "" {
return id
}
resp, ok := raw["responsible"].(map[string]any)
if !ok {
return ""
}
return strRaw(resp, "id")
}
// IsDocumentKind is true when the right pane should show rendered preview content, not an editable form.
func IsDocumentKind(kind Kind) bool {
return kind == KindFile || kind == KindMail
}
+42
View File
@@ -0,0 +1,42 @@
package model
import "testing"
func TestTaskStatusLifecycleCycle(t *testing.T) {
cur := TaskLifecycleOpen
cur = cur.Next()
if cur != TaskLifecycleClosed {
t.Fatalf("got %v", cur)
}
cur = cur.Prev()
if cur != TaskLifecycleOpen {
t.Fatalf("got %v", cur)
}
}
func TestFormFieldsFromRawTask(t *testing.T) {
raw := map[string]any{
"title": "Fix",
"description": "Details",
"status": 1,
"projectOwner": map[string]any{"title": "Alpha"},
"startDate": "2026-06-01T10:00:00Z",
"deadline": "2026-06-10T18:00:00Z",
"responsibles": []any{
map[string]any{"id": "u1", "displayName": "Alice"},
},
}
f := FormFieldsFromRaw(KindTask, raw)
if !f.HasTaskStatus || f.TaskStatus != TaskLifecycleOpen {
t.Fatalf("status: %+v", f)
}
if !f.HasResponsible || f.ResponsibleID != "u1" {
t.Fatalf("responsible: %+v", f)
}
if f.ProjectTitle != "Alpha" {
t.Fatalf("project: %q", f.ProjectTitle)
}
if f.TimingSummary == "" {
t.Fatal("expected timing summary")
}
}
+23
View File
@@ -0,0 +1,23 @@
package model
import "testing"
func TestFormFieldsFromRawMail(t *testing.T) {
raw := map[string]any{"subject": "Hi", "body": "Hello"}
f := FormFieldsFromRaw(KindMail, raw)
if f.Primary != "Hi" || f.Secondary != "Hello" || !f.ReadOnly {
t.Fatalf("unexpected mail fields: %+v", f)
}
}
func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) {
t.Fatal("file should be document")
}
if !IsDocumentKind(KindMail) {
t.Fatal("mail should be document preview")
}
if IsDocumentKind(KindTask) {
t.Fatal("task should be form")
}
}
+137
View File
@@ -0,0 +1,137 @@
package model
// Kind identifies the OnlyOffice entity type for list items and preview routing.
type Kind string
const (
KindProject Kind = "project"
KindTask Kind = "task"
KindContact Kind = "contact"
KindOpportunity Kind = "opportunity"
KindCase Kind = "case"
KindCRMTask Kind = "crm_task"
KindMail Kind = "mail"
KindEvent Kind = "event"
KindCalendar Kind = "calendar"
KindFile Kind = "file"
KindUser Kind = "user"
)
// Subject identifies a menu leaf / list source.
type Subject string
const (
SubjectProjects Subject = "projects"
SubjectTasks Subject = "tasks"
SubjectCalendars Subject = "calendars"
SubjectEvents Subject = "events"
SubjectContacts Subject = "contacts"
SubjectPersons Subject = "persons"
SubjectCompanies Subject = "companies"
SubjectOpportunities Subject = "opportunities"
SubjectCases Subject = "cases"
SubjectCRMTasks Subject = "crm_tasks"
SubjectMailInbox Subject = "mail_inbox"
SubjectMailSent Subject = "mail_sent"
SubjectMailDrafts Subject = "mail_drafts"
SubjectMailTrash Subject = "mail_trash"
SubjectMailSpam Subject = "mail_spam"
SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users"
SubjectCalendar Subject = "calendar"
)
// SubjectIsMail reports whether a list subject loads mailbox messages.
func SubjectIsMail(s Subject) bool {
switch s {
case SubjectMailInbox, SubjectMailSent, SubjectMailDrafts, SubjectMailTrash, SubjectMailSpam:
return true
default:
return false
}
}
// FocusPane is which column has keyboard focus.
type FocusPane int
const (
FocusMenu FocusPane = iota
FocusList
FocusPreview
)
// PrevFocusPane cycles preview → list → menu → preview.
func PrevFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusPreview
case FocusList:
return FocusMenu
default:
return FocusList
}
}
// NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusList
case FocusList:
return FocusPreview
default:
return FocusMenu
}
}
// Item is one row in the center list pane.
type Item struct {
ID, Title, Subtitle string
Kind Kind
Raw map[string]any
Selected bool
}
// Selection tracks multi-selected item IDs within the current subject.
type Selection struct {
ids map[string]struct{}
}
// NewSelection returns an empty selection set.
func NewSelection() *Selection {
return &Selection{ids: make(map[string]struct{})}
}
// Toggle flips selection on items[idx] and updates the ID set.
func (s *Selection) Toggle(items *[]Item, idx int) {
if idx < 0 || idx >= len(*items) {
return
}
it := &(*items)[idx]
it.Selected = !it.Selected
if it.Selected {
s.ids[it.ID] = struct{}{}
} else {
delete(s.ids, it.ID)
}
}
// Count returns the number of selected items.
func (s *Selection) Count() int {
return len(s.ids)
}
// Clear removes all selections.
func (s *Selection) Clear() {
s.ids = make(map[string]struct{})
}
// IDs returns selected item IDs in stable order (map iteration order is fine for display).
func (s *Selection) IDs() []string {
out := make([]string, 0, len(s.ids))
for id := range s.ids {
out = append(out, id)
}
return out
}
+12
View File
@@ -0,0 +1,12 @@
package model
import "testing"
func TestSubjectIsMail(t *testing.T) {
if !SubjectIsMail(SubjectMailInbox) {
t.Fatal("inbox should be mail")
}
if SubjectIsMail(SubjectProjects) {
t.Fatal("projects should not be mail")
}
}
+45
View File
@@ -0,0 +1,45 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestSelectionToggle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "A"},
{ID: "2", Title: "B"},
}
sel := model.NewSelection()
if sel.Count() != 0 {
t.Fatalf("initial count=%d", sel.Count())
}
sel.Toggle(&items, 0)
if !items[0].Selected || sel.Count() != 1 {
t.Fatalf("after toggle 0: selected=%v count=%d", items[0].Selected, sel.Count())
}
sel.Toggle(&items, 0)
if items[0].Selected || sel.Count() != 0 {
t.Fatalf("after second toggle: selected=%v count=%d", items[0].Selected, sel.Count())
}
}
func TestSelectionClearOnSubjectChange(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A", Selected: true}}
sel := model.NewSelection()
sel.Toggle(&items, 0)
sel.Clear()
if sel.Count() != 0 {
t.Fatalf("count=%d after clear", sel.Count())
}
}
func TestNextFocusPane(t *testing.T) {
if got := model.NextFocusPane(model.FocusMenu); got != model.FocusList {
t.Fatalf("menu->list got %v", got)
}
if got := model.NextFocusPane(model.FocusPreview); got != model.FocusMenu {
t.Fatalf("preview->menu got %v", got)
}
}
+253
View File
@@ -0,0 +1,253 @@
package model
import "strings"
// ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct {
Subject Subject
ProjectID string
TaskID string
}
// NavNode is one row in the navigation tree.
type NavNode struct {
ID string
Label string
Branch bool
List *ListSpec // set on leaves that open the center list
ParentID string
}
// NavTree is a hierarchical navigator; the center list loads only on leaves.
type NavTree struct {
nodes map[string]NavNode
roots []string
children map[string][]string
expanded map[string]bool
cursor int
visible []string
filter string
}
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
func DefaultNavTree() *NavTree {
t := &NavTree{
nodes: make(map[string]NavNode),
children: make(map[string][]string),
expanded: make(map[string]bool),
}
add := func(id, label, parent string, branch bool, list *ListSpec) {
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
if parent == "" {
t.roots = append(t.roots, id)
} else {
t.children[parent] = append(t.children[parent], id)
}
}
add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "", true, nil)
add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
add("mail", "Mail", "", true, nil)
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
t.rebuildVisible()
return t
}
// RootLabels returns top-level labels (for tests).
func (t *NavTree) RootLabels() []string {
out := make([]string, len(t.roots))
for i, id := range t.roots {
out[i] = t.nodes[id].Label
}
return out
}
func (t *NavTree) Cursor() int { return t.cursor }
func (t *NavTree) VisibleCount() int { return len(t.visible) }
func (t *NavTree) NodeAtVisible(i int) (NavNode, bool) {
if i < 0 || i >= len(t.visible) {
return NavNode{}, false
}
n, ok := t.nodes[t.visible[i]]
return n, ok
}
func (t *NavTree) DepthAtVisible(i int) int {
if i < 0 || i >= len(t.visible) {
return 0
}
depth := 0
id := t.visible[i]
for {
n, ok := t.nodes[id]
if !ok || n.ParentID == "" {
break
}
depth++
id = n.ParentID
}
return depth
}
func (t *NavTree) IsExpandable(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return n.Branch && len(t.children[n.ID]) > 0
}
func (t *NavTree) IsExpanded(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return t.expanded[n.ID]
}
func (t *NavTree) ToggleExpand(i int) {
n, ok := t.NodeAtVisible(i)
if !ok || !n.Branch {
return
}
if t.expanded[n.ID] {
delete(t.expanded, n.ID)
} else {
t.expanded[n.ID] = true
}
t.rebuildVisible()
}
func (t *NavTree) MoveUp() { t.SetCursor(t.cursor - 1) }
func (t *NavTree) MoveDown() { t.SetCursor(t.cursor + 1) }
func (t *NavTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(t.visible) {
c = len(t.visible) - 1
}
if c < 0 {
c = 0
}
t.cursor = c
}
// CurrentListSpec returns the list spec when the cursor is on a leaf.
func (t *NavTree) CurrentListSpec() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok || n.List == nil {
return nil, false
}
spec := *n.List
return &spec, true
}
// Activate expands a branch or returns leaf list spec on Enter.
func (t *NavTree) Activate() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok {
return nil, false
}
if n.Branch {
t.expanded[n.ID] = true
t.rebuildVisible()
return nil, false
}
if n.List != nil {
spec := *n.List
return &spec, true
}
return nil, false
}
func (t *NavTree) SetFilter(query string) {
t.filter = strings.ToLower(strings.TrimSpace(query))
t.rebuildVisible()
}
func (t *NavTree) ClearFilter() {
t.filter = ""
t.rebuildVisible()
}
func (t *NavTree) FilterQuery() string { return t.filter }
func (t *NavTree) nodeLabelMatches(id string) bool {
if t.filter == "" {
return true
}
n, ok := t.nodes[id]
if !ok {
return false
}
return strings.Contains(strings.ToLower(n.Label), t.filter)
}
func (t *NavTree) hasMatchingDescendant(id string) bool {
for _, child := range t.children[id] {
if t.nodeLabelMatches(child) || t.hasMatchingDescendant(child) {
return true
}
}
return false
}
func (t *NavTree) visibleUnderFilter(id string) bool {
if t.filter == "" {
return true
}
return t.nodeLabelMatches(id) || t.hasMatchingDescendant(id)
}
func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0]
var walk func(id string)
walk = func(id string) {
if !t.visibleUnderFilter(id) {
return
}
t.visible = append(t.visible, id)
expanded := t.expanded[id]
if t.filter != "" && t.hasMatchingDescendant(id) {
expanded = true
}
if !expanded {
return
}
for _, child := range t.children[id] {
walk(child)
}
}
for _, root := range t.roots {
walk(root)
}
if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1
}
if t.cursor < 0 {
t.cursor = 0
}
}
+31
View File
@@ -0,0 +1,31 @@
package model
import "strings"
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
func (t *NavTree) InjectProjectNodes(projects []Item) {
// Drop prior dynamic project.* nodes.
for id := range t.nodes {
if strings.HasPrefix(id, "project.") {
delete(t.nodes, id)
}
}
t.children["projects.dynamic"] = nil
for _, p := range projects {
pid := "project." + p.ID
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
taskID := pid + ".tasks"
fileID := pid + ".files"
t.nodes[taskID] = NavNode{
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
}
t.nodes[fileID] = NavNode{
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
}
t.children[pid] = []string{taskID, fileID}
}
t.rebuildVisible()
}
+147
View File
@@ -0,0 +1,147 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree()
roots := tree.RootLabels()
want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree()
var crm int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "CRM" {
crm = i
break
}
}
if crm < 0 {
t.Fatal("CRM node not found")
}
if !tree.IsExpandable(crm) {
t.Fatal("CRM should be expandable")
}
tree.ToggleExpand(crm)
if !tree.IsExpanded(crm) {
t.Fatal("CRM should expand")
}
tree.ToggleExpand(crm)
if tree.IsExpanded(crm) {
t.Fatal("CRM should collapse")
}
}
func TestNavCalendarLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Calendar" {
found = i
break
}
}
if found < 0 {
t.Fatal("Calendar node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectCalendar {
t.Fatalf("spec=%v ok=%v", spec, ok)
}
}
func TestNavProjectsLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetCursor(0)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("projects spec=%v ok=%v", spec, ok)
}
}
func TestNavTasksLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Tasks" {
found = i
break
}
}
if found < 0 {
t.Fatal("Tasks node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectTasks {
t.Fatalf("tasks spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
}
func TestNavFilterByLabel(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetFilter("inbox")
found := false
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Inbox" {
found = true
break
}
}
if !found {
t.Fatal("Inbox should remain visible when filtering mail")
}
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Calendar" {
t.Fatal("Calendar should be hidden when filtering inbox")
}
}
}
func TestNavClearFilterRestoresTree(t *testing.T) {
tree := model.DefaultNavTree()
before := tree.VisibleCount()
tree.SetFilter("zzz-no-match")
if tree.VisibleCount() >= before {
t.Fatalf("filter should shrink visible nodes")
}
tree.ClearFilter()
if tree.VisibleCount() != before {
t.Fatalf("clear filter should restore visible count")
}
}
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
t.Fatalf("expected delete only, got %v", acts)
}
}
+106
View File
@@ -0,0 +1,106 @@
package model
// ProjectLifecycle is the OnlyOffice project status string accepted by the API.
type ProjectLifecycle string
const (
ProjectLifecycleOpen ProjectLifecycle = "open"
ProjectLifecyclePaused ProjectLifecycle = "paused"
ProjectLifecycleClosed ProjectLifecycle = "closed"
)
// ProjectStatusFromAny maps API status (int or string) to a lifecycle value.
func ProjectStatusFromAny(v any) ProjectLifecycle {
switch x := v.(type) {
case string:
switch ProjectLifecycle(x) {
case ProjectLifecycleOpen, ProjectLifecyclePaused, ProjectLifecycleClosed:
return ProjectLifecycle(x)
}
case float64:
return projectStatusFromInt(int(x))
case int:
return projectStatusFromInt(x)
case int64:
return projectStatusFromInt(int(x))
}
return ProjectLifecycleOpen
}
func projectStatusFromInt(n int) ProjectLifecycle {
switch n {
case 1:
return ProjectLifecyclePaused
case 2:
return ProjectLifecycleClosed
default:
return ProjectLifecycleOpen
}
}
// ProjectStatusLabel returns a short UI label for list cells.
func ProjectStatusLabel(raw map[string]any) string {
if raw == nil {
return "Open"
}
return ProjectStatusFromAny(raw["status"]).Label()
}
// ProjectStatusIcon returns the list-cell emoji for a project lifecycle state.
func (s ProjectLifecycle) Icon() string {
switch s {
case ProjectLifecycleClosed:
return "🔴"
case ProjectLifecyclePaused:
return "🟡"
default:
return "🟢"
}
}
// ProjectStatusCell returns emoji and label on one line for project table cells.
func ProjectStatusCell(raw map[string]any) string {
if raw == nil {
return ProjectLifecycleOpen.Icon() + " Open"
}
s := ProjectStatusFromAny(raw["status"])
return s.Icon() + " " + s.Label()
}
// ProjectIsOpen is true when the project is not closed.
func ProjectIsOpen(raw map[string]any) bool {
if raw == nil {
return true
}
return ProjectStatusFromAny(raw["status"]) != ProjectLifecycleClosed
}
// Label returns a human-readable status name.
func (s ProjectLifecycle) Label() string {
switch s {
case ProjectLifecycleClosed:
return "Closed"
case ProjectLifecyclePaused:
return "Paused"
default:
return "Open"
}
}
// ToggleOpenClosed flips between open and closed (paused maps to closed on toggle).
func (s ProjectLifecycle) ToggleOpenClosed() ProjectLifecycle {
if s == ProjectLifecycleClosed {
return ProjectLifecycleOpen
}
return ProjectLifecycleClosed
}
// Next cycles open → closed → open for keyboard toggling.
func (s ProjectLifecycle) Next() ProjectLifecycle {
return s.ToggleOpenClosed()
}
// Prev cycles closed → open → closed.
func (s ProjectLifecycle) Prev() ProjectLifecycle {
return s.ToggleOpenClosed()
}
+26
View File
@@ -0,0 +1,26 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestProjectStatusFromInt(t *testing.T) {
if got := model.ProjectStatusFromAny(2); got != model.ProjectLifecycleClosed {
t.Fatalf("got %q", got)
}
if got := model.ProjectStatusFromAny(0); got != model.ProjectLifecycleOpen {
t.Fatalf("got %q", got)
}
}
func TestProjectStatusToggle(t *testing.T) {
s := model.ProjectLifecycleOpen
if s.Next() != model.ProjectLifecycleClosed {
t.Fatal("expected closed")
}
if s.Next().Prev() != model.ProjectLifecycleOpen {
t.Fatal("expected open after toggle back")
}
}
+20
View File
@@ -0,0 +1,20 @@
package model
// TableFlexLayout describes a list table where every column stays visible and
// one flex column absorbs leftover pane width.
type TableFlexLayout struct {
FlexColumnKey string
MinFlexWidth int
}
// TableFlexLayoutFor returns a flex layout policy for subjects that show all columns.
func TableFlexLayoutFor(subject Subject) (TableFlexLayout, bool) {
switch subject {
case SubjectProjects:
return TableFlexLayout{FlexColumnKey: "title", MinFlexWidth: 12}, true
case SubjectUsers:
return TableFlexLayout{FlexColumnKey: "email", MinFlexWidth: 16}, true
default:
return TableFlexLayout{}, false
}
}
+18
View File
@@ -0,0 +1,18 @@
package model
import "testing"
func TestTableFlexLayoutFor(t *testing.T) {
proj, ok := TableFlexLayoutFor(SubjectProjects)
if !ok || proj.FlexColumnKey != "title" || proj.MinFlexWidth != 12 {
t.Fatalf("projects layout=%+v ok=%v", proj, ok)
}
users, ok := TableFlexLayoutFor(SubjectUsers)
if !ok || users.FlexColumnKey != "email" {
t.Fatalf("users layout=%+v", users)
}
_, ok = TableFlexLayoutFor(SubjectTasks)
if ok {
t.Fatal("tasks should use scrolling layout")
}
}
+188
View File
@@ -0,0 +1,188 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
)
// TaskStatusLabel returns a human-readable project/CRM task status.
func TaskStatusLabel(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
if label := taskStatusFromString(s); label != "" {
return label
}
}
n := intRawVal(map[string]any{"status": v}, "status")
switch n {
case 0:
return "Not accepted"
case 1:
return "Open"
case 2:
return "Closed"
case 3:
return "Disabled"
case 4:
return "Unclassified"
case 5:
return "Not in milestone"
default:
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
return ""
}
}
func taskStatusFromString(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "open":
return "Open"
case "closed":
return "Closed"
case "notaccept", "not accept", "not accepted":
return "Not accepted"
case "disable", "disabled":
return "Disabled"
case "unclassified":
return "Unclassified"
case "notinmilestone", "not in milestone":
return "Not in milestone"
default:
return ""
}
}
// FormatRelativeDeadline formats a deadline as "in 2 hours" or "3 days ago".
func FormatRelativeDeadline(v any) string {
return FormatRelativeDeadlineAt(v, time.Now())
}
// FormatRelativeDeadlineAt is FormatRelativeDeadline with a fixed clock (for tests).
func FormatRelativeDeadlineAt(v any, now time.Time) string {
t, ok := parseDeadlineTime(v)
if !ok {
return ""
}
return relativeTimePhrase(now, t)
}
func relativeTimePhrase(now, target time.Time) string {
d := target.Sub(now)
future := d >= 0
if !future {
d = -d
}
phrase := func(n int, unit string) string {
if n == 1 {
unit = strings.TrimSuffix(unit, "s")
}
if future {
return fmt.Sprintf("in %d %s", n, unit)
}
return fmt.Sprintf("%d %s ago", n, unit)
}
switch {
case d < time.Minute:
if future {
return "in 1 minute"
}
return "just now"
case d < time.Hour:
return phrase(int(d.Round(time.Minute)/time.Minute), "minutes")
case d < 24*time.Hour:
return phrase(int(d.Round(time.Hour)/time.Hour), "hours")
default:
return phrase(int(d.Round(24*time.Hour)/(24*time.Hour)), "days")
}
}
func parseDeadlineTime(v any) (time.Time, bool) {
if v == nil {
return time.Time{}, false
}
switch x := v.(type) {
case time.Time:
return x, true
case string:
s := strings.TrimSpace(x)
if s == "" {
return time.Time{}, false
}
formats := []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.0000000-07:00",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range formats {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
case float64:
return time.Unix(int64(x), 0), true
case int64:
return time.Unix(x, 0), true
case int:
return time.Unix(int64(x), 0), true
default:
return parseDeadlineTime(fmt.Sprint(v))
}
}
// TaskResponsibleLabel returns the assignee display name when present.
func TaskResponsibleLabel(raw map[string]any) string {
if raw == nil {
return ""
}
if name := personName(raw["responsible"]); name != "" {
return name
}
if list, ok := raw["responsibles"].([]any); ok {
names := make([]string, 0, len(list))
for _, entry := range list {
if name := personName(entry); name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
if s := strRaw(raw, "responsible"); s != "" && !looksLikeID(s) {
return s
}
return ""
}
func personName(v any) string {
m, ok := v.(map[string]any)
if !ok {
return ""
}
if name := strRaw(m, "displayName"); name != "" {
if strings.EqualFold(name, "Profile has been removed") {
return ""
}
return name
}
first := strRaw(m, "firstName")
last := strRaw(m, "lastName")
return strings.TrimSpace(first + " " + last)
}
func looksLikeID(s string) bool {
if len(s) == 36 && strings.Count(s, "-") == 4 {
return true
}
_, err := strconv.Atoi(s)
return err == nil
}
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"testing"
"time"
)
func TestTaskStatusLabelFromInt(t *testing.T) {
if got := TaskStatusLabel(1); got != "Open" {
t.Fatalf("got %q", got)
}
if got := TaskStatusLabel(2); got != "Closed" {
t.Fatalf("got %q", got)
}
}
func TestFormatRelativeDeadlineFuture(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
deadline := now.Add(2 * time.Hour)
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
if got != "in 2 hours" {
t.Fatalf("got %q", got)
}
}
func TestFormatRelativeDeadlineDays(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
deadline := now.Add(26 * time.Hour)
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
if got != "in 1 day" {
t.Fatalf("got %q", got)
}
}
func TestBuildTaskColumnsOmitsSubtitle(t *testing.T) {
items := []Item{{
ID: "1", Title: "Fix bug", Kind: KindTask, Subtitle: "1",
Raw: map[string]any{"status": 1, "deadline": "2026-06-25T12:00:00Z"},
}}
cols := BuildColumns(SubjectTasks, items)
for _, c := range cols {
if c.Key == "subtitle" {
t.Fatalf("unexpected subtitle column")
}
}
if CellText(items[0], "status") != "Open" {
t.Fatalf("status not humanized")
}
}
+87
View File
@@ -0,0 +1,87 @@
package model
// TaskLifecycle is the OnlyOffice project task status code.
type TaskLifecycle int
const (
TaskLifecycleNotAccept TaskLifecycle = 0
TaskLifecycleOpen TaskLifecycle = 1
TaskLifecycleClosed TaskLifecycle = 2
TaskLifecycleDisabled TaskLifecycle = 3
TaskLifecycleUnclassified TaskLifecycle = 4
TaskLifecycleNotInMilestone TaskLifecycle = 5
)
var taskLifecycleCycle = []TaskLifecycle{
TaskLifecycleOpen,
TaskLifecycleClosed,
TaskLifecycleNotAccept,
TaskLifecycleDisabled,
TaskLifecycleUnclassified,
TaskLifecycleNotInMilestone,
}
// TaskStatusFromAny maps API status to a lifecycle value.
func TaskStatusFromAny(v any) TaskLifecycle {
if v == nil {
return TaskLifecycleOpen
}
if s, ok := v.(string); ok {
switch taskStatusFromString(s) {
case "Open":
return TaskLifecycleOpen
case "Closed":
return TaskLifecycleClosed
case "Not accepted":
return TaskLifecycleNotAccept
case "Disabled":
return TaskLifecycleDisabled
case "Unclassified":
return TaskLifecycleUnclassified
case "Not in milestone":
return TaskLifecycleNotInMilestone
}
}
switch n := intRawVal(map[string]any{"status": v}, "status"); n {
case 0:
return TaskLifecycleNotAccept
case 2:
return TaskLifecycleClosed
case 3:
return TaskLifecycleDisabled
case 4:
return TaskLifecycleUnclassified
case 5:
return TaskLifecycleNotInMilestone
default:
return TaskLifecycleOpen
}
}
// Label returns a human-readable status name.
func (s TaskLifecycle) Label() string {
return TaskStatusLabel(int(s))
}
// Next cycles task status for keyboard toggling.
func (s TaskLifecycle) Next() TaskLifecycle {
return cycleTaskLifecycle(s, 1)
}
// Prev cycles task status backward.
func (s TaskLifecycle) Prev() TaskLifecycle {
return cycleTaskLifecycle(s, -1)
}
func cycleTaskLifecycle(cur TaskLifecycle, delta int) TaskLifecycle {
idx := 0
for i, v := range taskLifecycleCycle {
if v == cur {
idx = i
break
}
}
n := len(taskLifecycleCycle)
idx = (idx + delta%n + n) % n
return taskLifecycleCycle[idx]
}
+93
View File
@@ -0,0 +1,93 @@
package model
import (
"fmt"
"strings"
)
// UserACLDef is one administrator permission row in the user editor.
type UserACLDef struct {
Key string
Label string
}
// UserACLDefs lists portal ACL rows (Full access + per-module grants).
var UserACLDefs = []UserACLDef{
{Key: "full", Label: "Full access"},
{Key: "documents", Label: "Documents"},
{Key: "projects", Label: "Projects"},
{Key: "crm", Label: "CRM"},
{Key: "community", Label: "Community"},
{Key: "people", Label: "People"},
{Key: "sample", Label: "Sample"},
{Key: "mail", Label: "Mail"},
}
// UserACLState is the editable ACL snapshot for one user.
type UserACLState struct {
FullAccess bool
Modules map[string]bool
}
// UserACLFromRaw parses API user detail into ACL editor state.
func UserACLFromRaw(raw map[string]any) UserACLState {
modules := make(map[string]bool, len(UserACLDefs))
for _, def := range UserACLDefs[1:] {
modules[def.Key] = false
}
if list, ok := raw["listAdminModules"].([]any); ok {
for _, v := range list {
key := strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
if key != "" {
modules[key] = true
}
}
}
full := boolRaw(raw, "isAdmin")
if full {
for k := range modules {
modules[k] = true
}
}
return UserACLState{FullAccess: full, Modules: modules}
}
// APIPayload converts editor state to OnlyOffice update fields.
func (s UserACLState) APIPayload() (isAdmin bool, modules []string) {
if s.FullAccess {
return true, nil
}
for _, def := range UserACLDefs[1:] {
if s.Modules[def.Key] {
modules = append(modules, def.Key)
}
}
return false, modules
}
// ACLModuleOn reports whether a module row should show as granted.
func (s UserACLState) ACLModuleOn(key string) bool {
if key == "full" {
return s.FullAccess
}
if s.FullAccess {
return true
}
return s.Modules[key]
}
func boolRaw(m map[string]any, key string) bool {
if m == nil {
return false
}
switch v := m[key].(type) {
case bool:
return v
case float64:
return v != 0
case int:
return v != 0
default:
return false
}
}
+43
View File
@@ -0,0 +1,43 @@
package model
import "testing"
func TestUserACLFromRawFullAccess(t *testing.T) {
raw := map[string]any{
"isAdmin": true,
"listAdminModules": []any{"crm"},
}
acl := UserACLFromRaw(raw)
if !acl.FullAccess {
t.Fatal("expected full access")
}
if !acl.ACLModuleOn("documents") {
t.Fatal("full access should show all modules on")
}
}
func TestUserACLAPIPayloadPartial(t *testing.T) {
acl := UserACLState{
FullAccess: false,
Modules: map[string]bool{
"documents": true,
"projects": true,
},
}
isAdmin, mods := acl.APIPayload()
if isAdmin {
t.Fatal("expected partial admin")
}
if len(mods) != 2 {
t.Fatalf("mods=%v", mods)
}
}
func TestUserIsEnabled(t *testing.T) {
if !UserIsEnabled(map[string]any{"status": EmployeeStatusActive}) {
t.Fatal("active user should be enabled")
}
if UserIsEnabled(map[string]any{"status": EmployeeStatusTerminated}) {
t.Fatal("terminated user should be disabled")
}
}
+7
View File
@@ -0,0 +1,7 @@
package model
// UserOption is one portal user for responsible pickers.
type UserOption struct {
ID string
Name string
}
+116
View File
@@ -0,0 +1,116 @@
package model
import "time"
const (
EmployeeStatusActive = 1
EmployeeStatusTerminated = 2
EmployeeStatusPending = 4
)
// UserIsEnabled reports whether the portal user account is active.
func UserIsEnabled(raw map[string]any) bool {
if raw == nil {
return true
}
if t, ok := raw["terminated"].(bool); ok && t {
return false
}
switch intRawVal(raw, "status") {
case EmployeeStatusTerminated:
return false
case EmployeeStatusPending:
return false
default:
return true
}
}
// UserStatusLabel returns a short list-cell label for account state.
func UserStatusLabel(raw map[string]any) string {
if !UserIsEnabled(raw) {
return "Disabled"
}
if intRawVal(raw, "status") == EmployeeStatusPending {
return "Pending"
}
return "Active"
}
// UserGroupsText formats group membership for the detail form.
func UserGroupsText(raw map[string]any) string {
if raw == nil {
return "—"
}
list, ok := raw["groups"].([]any)
if !ok || len(list) == 0 {
return "—"
}
names := make([]string, 0, len(list))
for _, g := range list {
m, ok := g.(map[string]any)
if !ok {
continue
}
name := strRaw(m, "name")
if name == "" {
name = strRaw(m, "title")
}
if name != "" {
names = append(names, name)
}
}
if len(names) == 0 {
return "—"
}
return stringsJoin(names, ", ")
}
func stringsJoin(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
out := parts[0]
for i := 1; i < len(parts); i++ {
out += sep + parts[i]
}
return out
}
// FormatUserRegistration formats registration or work-from date for list cells.
func FormatUserRegistration(raw map[string]any) string {
if raw == nil {
return ""
}
for _, key := range []string{"registrationDate", "workFrom"} {
if s := formatUserDate(raw[key]); s != "" {
return s
}
}
return ""
}
func formatUserDate(v any) string {
switch t := v.(type) {
case string:
if t == "" {
return ""
}
if parsed, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", t); err == nil {
return parsed.Format("2006-01-02")
}
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
return parsed.Format("2006-01-02")
}
if len(t) >= 10 {
return t[:10]
}
return t
default:
s := strRaw(map[string]any{"v": v}, "v")
if s == "" || s == "<nil>" {
return ""
}
return formatUserDate(s)
}
}
+58
View File
@@ -0,0 +1,58 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestNavUsersLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Users" {
found = i
break
}
}
if found < 0 {
t.Fatal("Users node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectUsers {
t.Fatalf("users spec=%v ok=%v", spec, ok)
}
if tree.IsExpandable(found) {
t.Fatal("Users should be a direct leaf, not a branch")
}
}
func TestBuildUserColumnsOmitsIDAndDisplayName(t *testing.T) {
items := []model.Item{{
ID: "1", Title: "jdoe", Kind: model.KindUser,
Raw: map[string]any{
"id": "1", "userName": "jdoe", "displayName": "John Doe",
"email": "j@example.com", "registrationDate": "2024-01-02T00:00:00",
},
}}
cols := model.BuildColumns(model.SubjectUsers, items)
want := []string{"_sel", "userName", "registration", "status", "email"}
if len(cols) != len(want) {
t.Fatalf("got %v", columnKeys(cols))
}
for i, key := range want {
if cols[i].Key != key {
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
}
}
}
func columnKeys(cols []model.TableColumn) []string {
out := make([]string, len(cols))
for i, c := range cols {
out[i] = c.Key
}
return out
}
+10
View File
@@ -0,0 +1,10 @@
package preview
import (
md "github.com/JohannesKaufmann/html-to-markdown/v2"
)
// HTMLToMarkdown converts Document Server HTML output to markdown for TUI preview.
func HTMLToMarkdown(html string) (string, error) {
return md.ConvertString(html)
}
+19
View File
@@ -0,0 +1,19 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestHTMLToMarkdown(t *testing.T) {
html := `<h1>Title</h1><p>Body text</p>`
md, err := preview.HTMLToMarkdown(html)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "Title") || !strings.Contains(md, "Body text") {
t.Fatalf("conversion failed: %q", md)
}
}
+214
View File
@@ -0,0 +1,214 @@
package preview
import (
"fmt"
"html"
"regexp"
"strings"
)
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
// ContactMarkdown formats a CRM contact or company for preview.
func ContactMarkdown(m map[string]any) string {
name := str(m, "displayName")
if name == "" {
name = strings.TrimSpace(str(m, "firstName") + " " + str(m, "lastName"))
}
kind := "Person"
if boolVal(m, "isCompany") {
kind = "Company"
}
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", name)
fmt.Fprintf(&b, "**Type:** %s\n\n", kind)
if about := str(m, "about"); about != "" {
fmt.Fprintf(&b, "## About\n\n%s\n\n", about)
}
appendInfoList(&b, m)
return strings.TrimSpace(b.String()) + "\n"
}
// OpportunityMarkdown formats a CRM deal for preview.
func OpportunityMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if v := m["bidValue"]; v != nil {
fmt.Fprintf(&b, "**Value:** %v %s\n\n", v, str(m, "bidCurrency"))
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown).
func MailMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
if to := mailAddressLine(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to)
}
if cc := mailAddressLine(m, "cc"); cc != "" {
fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
}
if date := str(m, "date"); date != "" {
fmt.Fprintf(&b, "**Date:** %s\n\n", date)
}
if body := mailBodyMarkdown(m); body != "" {
fmt.Fprintf(&b, "---\n\n%s\n", body)
}
return strings.TrimSpace(b.String()) + "\n"
}
func mailAddressLine(m map[string]any, key string) string {
if v := str(m, key); v != "" {
return v
}
name := str(m, key+"Name")
addr := str(m, key+"Address")
switch {
case name != "" && addr != "":
return fmt.Sprintf("%s <%s>", name, addr)
case addr != "":
return addr
default:
return name
}
}
func mailBodyMarkdown(m map[string]any) string {
htmlBody := strings.TrimSpace(str(m, "htmlBody"))
plainBody := strings.TrimSpace(str(m, "body"))
for _, candidate := range []string{htmlBody, plainBody} {
if candidate == "" {
continue
}
if looksLikeHTML(candidate) {
if md, err := HTMLToMarkdown(candidate); err == nil {
if md = strings.TrimSpace(md); md != "" {
return md
}
}
}
}
if plainBody != "" {
return plainBody
}
if htmlBody != "" {
return stripHTML(htmlBody)
}
return ""
}
func looksLikeHTML(s string) bool {
return strings.Contains(s, "<") && strings.Contains(s, ">") && htmlTagRe.MatchString(s)
}
// EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
fmt.Fprintf(&b, "**Start:** %s\n\n", str(m, "start"))
fmt.Fprintf(&b, "**End:** %s\n\n", str(m, "end"))
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// TaskMarkdown formats a project or CRM task for preview.
func TaskMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "deadline"); d != "" {
fmt.Fprintf(&b, "**Deadline:** %s\n\n", d)
}
if desc := str(m, "description"); desc != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", desc)
}
return strings.TrimSpace(b.String()) + "\n"
}
// ProjectMarkdown formats a project for preview.
func ProjectMarkdown(m map[string]any) string {
var b strings.Builder
title := str(m, "title")
if title == "" {
title = str(m, "name")
}
fmt.Fprintf(&b, "# %s\n\n", title)
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// EntityMarkdown picks a formatter based on item kind.
func EntityMarkdown(kind string, m map[string]any) string {
switch kind {
case "contact", "person", "company":
return ContactMarkdown(m)
case "opportunity":
return OpportunityMarkdown(m)
case "mail":
return MailMarkdown(m)
case "event":
return EventMarkdown(m)
case "task", "crm_task":
return TaskMarkdown(m)
case "project":
return ProjectMarkdown(m)
default:
return mapToMarkdown(m)
}
}
func mapToMarkdown(m map[string]any) string {
var b strings.Builder
b.WriteString("# Details\n\n")
for k, v := range m {
fmt.Fprintf(&b, "- **%s:** %v\n", k, v)
}
return b.String()
}
func appendInfoList(b *strings.Builder, m map[string]any) {
if infos, ok := m["contactInfos"].([]any); ok && len(infos) > 0 {
b.WriteString("## Contact info\n\n")
for _, raw := range infos {
if row, ok := raw.(map[string]any); ok {
fmt.Fprintf(b, "- %s: %s\n", str(row, "infoType"), str(row, "data"))
}
}
b.WriteString("\n")
}
}
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
func boolVal(m map[string]any, key string) bool {
v, ok := m[key].(bool)
return ok && v
}
+58
View File
@@ -0,0 +1,58 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestMailMarkdownConvertsHTMLBody(t *testing.T) {
raw := map[string]any{
"subject": "Weekly update",
"from": `"Team" <team@example.com>`,
"htmlBody": `<p>Hello <strong>world</strong></p><p><a href="https://example.com">Link</a></p>`,
}
md := preview.MailMarkdown(raw)
if !strings.Contains(md, "Weekly update") {
t.Fatalf("missing subject:\n%s", md)
}
if !strings.Contains(md, "team@example.com") {
t.Fatalf("missing from:\n%s", md)
}
if strings.Contains(md, "<strong>") || strings.Contains(md, "<p>") {
t.Fatalf("expected HTML converted, got raw tags:\n%s", md)
}
if !strings.Contains(md, "world") {
t.Fatalf("missing body text:\n%s", md)
}
}
func TestMailMarkdownRendersWithColor(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Styled",
"from": "a@b.com",
"htmlBody": `<h2>Heading</h2><p>Plain text</p>`,
})
out, err := preview.RenderMarkdown(md, 60)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) == "" {
t.Fatal("empty render output")
}
if !strings.Contains(out, "Heading") || !strings.Contains(out, "Plain text") {
t.Fatalf("missing rendered content:\n%s", out)
}
}
func TestMailMarkdownPlainBodyFallback(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Hi",
"from": "a@b.com",
"body": "Just plain text",
})
if !strings.Contains(md, "Just plain text") {
t.Fatalf("missing plain body:\n%s", md)
}
}
@@ -0,0 +1,214 @@
//go:build integration
package preview_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func skipWithoutLiveAPI(t *testing.T) {
t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
}
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
t.Helper()
skipWithoutLiveAPI(t)
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
return &fetch.Loader{Client: client}, context.Background()
}
func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.EntityMarkdown(string(model.KindProject), raw)
if md == "" {
t.Fatal("empty markdown")
}
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing project title %q:\n%s", items[0].Title, md)
}
rendered, err := preview.RenderMarkdown(md, 80)
if err != nil {
t.Fatal(err)
}
if rendered == "" {
t.Fatal("glamour produced empty output")
}
}
func TestIntegrationPreviewContactFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no contacts")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.ContactMarkdown(raw)
if md == "" || !strings.Contains(md, "#") {
t.Fatalf("unexpected contact markdown: %q", md)
}
}
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no opportunities")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.OpportunityMarkdown(raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing deal title:\n%s", md)
}
}
func TestIntegrationPreviewMailFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("inbox empty")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.MailMarkdown(raw)
if md == "" || !strings.Contains(md, "From:") {
t.Fatalf("unexpected mail markdown:\n%s", md)
}
}
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no events in range")
}
md := preview.EventMarkdown(items[0].Raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing event title:\n%s", md)
}
}
func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.TaskMarkdown(raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing task title:\n%s", md)
}
}
func TestIntegrationPreviewFileFromAPI(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no project files")
}
md, err := loader.PreviewMarkdown(ctx, items[0])
if err != nil {
t.Fatalf("PreviewMarkdown: %v", err)
}
if md == "" {
t.Fatal("empty preview")
}
if !strings.Contains(md, items[0].Title) && !strings.Contains(md, "|") && !strings.Contains(md, "```") {
t.Fatalf("unexpected file preview for %q:\n%s", items[0].Title, md)
}
}
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil {
t.Fatal(err)
}
var csvItem *model.Item
for i := range items {
if strings.HasSuffix(strings.ToLower(items[i].Title), ".csv") {
csvItem = &items[i]
break
}
}
if csvItem == nil {
t.Skip("no csv file in project documents")
}
var buf strings.Builder
n, err := loader.Client.DownloadFile(ctx, csvItem.ID, &buf)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if n == 0 {
t.Fatal("empty download")
}
md, err := preview.CSVToMarkdownTable([]byte(buf.String()))
if err != nil {
t.Fatalf("CSVToMarkdownTable: %v", err)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
+23
View File
@@ -0,0 +1,23 @@
package preview
import (
"github.com/charmbracelet/glamour"
)
// RenderMarkdown renders GitHub-flavoured markdown for terminal display.
func RenderMarkdown(md string, width int) (string, error) {
if md == "" {
return "", nil
}
if width < 20 {
width = 20
}
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(width),
)
if err != nil {
return "", err
}
return r.Render(md)
}
+31
View File
@@ -0,0 +1,31 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestRenderMarkdown(t *testing.T) {
out, err := preview.RenderMarkdown("# Hello\n\nWorld", 40)
if err != nil {
t.Fatal(err)
}
if out == "" {
t.Fatal("empty render output")
}
if !strings.Contains(strings.ToLower(out), "hello") {
t.Fatalf("expected rendered heading: %q", out)
}
}
func TestRenderMarkdownEmpty(t *testing.T) {
out, err := preview.RenderMarkdown("", 40)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("expected empty, got %q", out)
}
}
+65
View File
@@ -0,0 +1,65 @@
package preview
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"strings"
xls "github.com/eslider/go-xls/v2"
)
// CSVToMarkdownTable parses CSV bytes into a GitHub pipe table markdown string.
func CSVToMarkdownTable(data []byte) (string, error) {
tab, err := parseCSV(data)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := xls.WriteMarkdownTable(&buf, tab); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()) + "\n", nil
}
func parseCSV(data []byte) (xls.Table, error) {
r := csv.NewReader(bytes.NewReader(data))
r.TrimLeadingSpace = true
records, err := r.ReadAll()
if err != nil {
return xls.Table{}, err
}
if len(records) == 0 {
return xls.Table{}, fmt.Errorf("preview: empty csv")
}
return xls.Table{Columns: records[0], Rows: records[1:]}, nil
}
// JSONToMarkdown wraps JSON in a fenced code block for preview.
func JSONToMarkdown(data []byte) (string, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return "", err
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("```json\n%s\n```\n", string(pretty)), nil
}
// FileBytesToMarkdown picks a preview format based on file extension.
func FileBytesToMarkdown(name string, data []byte) (string, error) {
ext := strings.ToLower(name)
switch {
case strings.HasSuffix(ext, ".csv"):
return CSVToMarkdownTable(data)
case strings.HasSuffix(ext, ".json"):
return JSONToMarkdown(data)
case strings.HasSuffix(ext, ".md"), strings.HasSuffix(ext, ".txt"):
return string(data), nil
default:
return fmt.Sprintf("```\n(binary or unsupported preview for %s, %d bytes)\n```\n", name, len(data)), nil
}
}
+32
View File
@@ -0,0 +1,32 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestCSVToMarkdownTable(t *testing.T) {
csv := "name,score\nAlice,10\nBob,20\n"
md, err := preview.CSVToMarkdownTable([]byte(csv))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "name") || !strings.Contains(md, "Alice") {
t.Fatalf("table missing data: %q", md)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
func TestJSONToMarkdown(t *testing.T) {
md, err := preview.JSONToMarkdown([]byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "```") || !strings.Contains(md, `"a"`) {
t.Fatalf("expected fenced json: %q", md)
}
}
+1049
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
package ui_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestKeyActionMenuDown(t *testing.T) {
if got := ui.KeyAction("j", model.FocusMenu); got != ui.ActionMoveDown {
t.Fatalf("got %v", got)
}
}
func TestKeyActionShiftTabPrevPane(t *testing.T) {
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
t.Fatalf("got %v", got)
}
}
func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
t.Fatalf("tab on preview should stay in pane, got %v", got)
}
}
func TestKeyActionSortAndColumns(t *testing.T) {
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
t.Fatalf("got %v", got)
}
}
func TestKeyActionFilter(t *testing.T) {
if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
}
}
+503
View File
@@ -0,0 +1,503 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
const detailContentRatio = 0.72
type detailMode int
const (
detailEmpty detailMode = iota
detailDocument
detailForm
)
type detailZone int
const (
detailZoneContent detailZone = iota
detailZoneActions
)
// DetailPane is the right column: content top, CRUD actions bottom.
type DetailPane struct {
mode detailMode
item model.Item
loadedID string
actions []model.ItemAction
actionIdx int
tabStop int
zone detailZone
focused bool
width int
height int
form EntityForm
docVP viewport.Model
formVP viewport.Model
docText string
styles detailStyles
}
type detailStyles struct {
sep lipgloss.Style
action lipgloss.Style
actionOn lipgloss.Style
actionDn lipgloss.Style
empty lipgloss.Style
}
func newDetailPane() DetailPane {
d := DetailPane{
form: newEntityForm(),
docVP: viewport.New(40, 10),
formVP: viewport.New(40, 10),
styles: newDetailStyles(),
}
d.docVP.MouseWheelEnabled = true
d.formVP.MouseWheelEnabled = true
return d
}
func newDetailStyles() detailStyles {
btn := lipgloss.NewStyle().Padding(0, 1).MarginRight(1)
return detailStyles{
sep: lipgloss.NewStyle().Foreground(lipgloss.Color("238")),
action: btn.Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")),
actionOn: btn.Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
actionDn: btn.Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
empty: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
}
}
func (d *DetailPane) Clear() {
d.mode = detailEmpty
d.item = model.Item{}
d.loadedID = ""
d.actions = nil
d.actionIdx = 0
d.tabStop = 0
d.docText = ""
d.form.Clear()
d.docVP.SetContent("")
d.formVP.SetContent("")
}
func (d *DetailPane) SetFocused(on bool) {
d.focused = on
d.applyTabStop(d.tabStop)
}
func (d *DetailPane) FocusFirstStop() {
d.tabStop = 0
d.applyTabStop(0)
}
func (d *DetailPane) maxTabStop() int {
switch d.mode {
case detailForm:
if d.form.readOnly {
if len(d.actions) == 0 {
return 0
}
return len(d.actions) - 1
}
fields := d.form.FieldCount()
if len(d.actions) == 0 {
if fields == 0 {
return 0
}
return fields - 1
}
return fields + len(d.actions) - 1
case detailDocument:
if len(d.actions) == 0 {
return 0
}
return len(d.actions)
default:
return 0
}
}
func (d *DetailPane) applyTabStop(stop int) {
if stop < 0 {
stop = 0
}
max := d.maxTabStop()
if stop > max {
stop = max
}
d.tabStop = stop
switch d.mode {
case detailForm:
if d.form.readOnly {
d.zone = detailZoneActions
d.actionIdx = stop
d.form.SetFocused(false)
return
}
fields := d.form.FieldCount()
if stop < fields {
d.zone = detailZoneContent
d.form.SetFieldIndex(stop)
d.form.SetFocused(d.focused)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - fields
d.form.SetFocused(false)
case detailDocument:
if stop == 0 {
d.zone = detailZoneContent
d.form.SetFocused(false)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - 1
d.form.SetFocused(false)
default:
d.zone = detailZoneContent
d.form.SetFocused(false)
}
}
// TabForward moves to the next field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabForward() bool {
max := d.maxTabStop()
if d.tabStop < max {
d.applyTabStop(d.tabStop + 1)
return false
}
d.FocusFirstStop()
return true
}
// TabBackward moves to the previous field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabBackward() bool {
if d.tabStop > 0 {
d.applyTabStop(d.tabStop - 1)
return false
}
d.applyTabStop(d.maxTabStop())
return true
}
func (d *DetailPane) SetSize(w, h int) {
if w < 8 {
w = 8
}
if h < 8 {
h = 8
}
d.width = w
d.height = h
contentH, _ := d.splitHeights()
d.form.SetSize(w, contentH)
d.docVP.Width = w
d.docVP.Height = contentH
d.formVP.Width = w
d.formVP.Height = contentH
}
func (d *DetailPane) splitHeights() (contentH, actionH int) {
actionH = int(float64(d.height) * (1 - detailContentRatio))
if actionH < 3 {
actionH = 3
}
if actionH > 6 {
actionH = 6
}
contentH = d.height - actionH - 1
if contentH < 4 {
contentH = 4
}
return contentH, actionH
}
func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
d.mode = detailForm
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.tabStop = 0
d.form.Load(item.Kind, item.ID, fields)
d.refreshFormViewport()
d.applyTabStop(0)
d.layoutContent()
}
func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth int) {
d.mode = detailDocument
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.tabStop = 0
d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil {
text = markdown
}
d.docText = text
d.docVP.SetContent(text)
d.docVP.GotoTop()
d.applyTabStop(0)
d.layoutContent()
}
func (d *DetailPane) layoutContent() {
contentH, _ := d.splitHeights()
d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width
d.docVP.Height = contentH
d.formVP.Width = d.width
d.formVP.Height = contentH
d.refreshFormViewport()
}
func (d *DetailPane) refreshFormViewport() {
if d.mode != detailForm || !d.form.readOnly {
d.formVP.SetContent("")
return
}
d.formVP.SetContent(d.form.View())
}
func (d *DetailPane) LoadedID() string { return d.loadedID }
func (d *DetailPane) Item() model.Item { return d.item }
func (d *DetailPane) Zone() detailZone { return d.zone }
func (d *DetailPane) FocusContent() {
d.applyTabStop(d.tabStop)
if d.zone == detailZoneContent && d.mode == detailForm {
d.form.SetFocused(d.focused)
}
}
func (d *DetailPane) FocusActions() {
max := d.maxTabStop()
switch d.mode {
case detailForm:
if d.form.readOnly {
d.applyTabStop(max)
return
}
if len(d.actions) > 0 {
d.applyTabStop(d.form.FieldCount())
}
case detailDocument:
if len(d.actions) > 0 {
d.applyTabStop(1)
}
}
}
// MoveTabStop moves between title, description, and action buttons without leaving the pane.
func (d *DetailPane) MoveTabStop(delta int) {
if delta == 0 {
return
}
next := d.tabStop + delta
if next < 0 {
next = 0
}
max := d.maxTabStop()
if next > max {
next = max
}
d.applyTabStop(next)
}
func (d *DetailPane) MoveAction(delta int) {
if len(d.actions) == 0 {
return
}
d.actionIdx = clampInt(d.actionIdx+delta, 0, len(d.actions)-1)
}
func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
if d.actionIdx < 0 || d.actionIdx >= len(d.actions) {
return model.ItemAction{}, false
}
return d.actions[d.actionIdx], true
}
func (d DetailPane) IsDocumentContent() bool {
return d.mode == detailDocument
}
func (d DetailPane) isReadOnlyFormContent() bool {
return d.mode == detailForm && d.form.readOnly
}
func (d DetailPane) documentContentHeight() int {
contentH, _ := d.splitHeights()
return contentH
}
// ScrollDocument scrolls the read-only document viewport (mail, file preview).
func (d *DetailPane) ScrollDocument(key string) bool {
if d.IsDocumentContent() {
return scrollViewport(&d.docVP, key)
}
if d.isReadOnlyFormContent() && d.zone == detailZoneContent {
return scrollViewport(&d.formVP, key)
}
return false
}
// ScrollDocumentMouse applies wheel events to scrollable preview content.
func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd {
var vp *viewport.Model
switch {
case d.IsDocumentContent():
vp = &d.docVP
case d.isReadOnlyFormContent():
vp = &d.formVP
default:
return nil
}
if msg.Y >= d.documentContentHeight() {
return nil
}
if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
return nil
}
switch msg.Button {
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
default:
return nil
}
var cmd tea.Cmd
*vp, cmd = vp.Update(msg)
return cmd
}
// DocumentYOffset exposes scroll position for tests.
func (d DetailPane) DocumentYOffset() int {
return d.docVP.YOffset
}
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused {
return nil
}
if d.zone == detailZoneContent {
switch d.mode {
case detailForm:
if d.form.readOnly {
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.formVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
d.formVP, cmd = d.formVP.Update(msg)
return cmd
}
return d.form.Update(msg)
case detailDocument:
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.docVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
d.docVP, cmd = d.docVP.Update(msg)
return cmd
}
}
return nil
}
func (d DetailPane) View() string {
if d.mode == detailEmpty {
return d.styles.empty.Render("Select a row to preview or edit.")
}
contentH, actionH := d.splitHeights()
var top string
switch d.mode {
case detailForm:
if d.form.readOnly {
top = ApplyVerticalScrollbar(
d.formVP.View(),
d.formVP.Width,
d.formVP.Height,
d.formVP.TotalLineCount(),
d.formVP.YOffset,
)
} else {
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
}
case detailDocument:
top = ApplyVerticalScrollbar(
d.docVP.View(),
d.docVP.Width,
d.docVP.Height,
d.docVP.TotalLineCount(),
d.docVP.YOffset,
)
default:
top = ""
}
sep := d.styles.sep.Width(d.width).Render(strings.Repeat("─", max(1, d.width)))
bottom := lipgloss.NewStyle().Width(d.width).Height(actionH).Render(d.renderActions())
return lipgloss.JoinVertical(lipgloss.Left, top, sep, bottom)
}
func (d DetailPane) renderActions() string {
if len(d.actions) == 0 {
hint := "No actions"
if d.focused && d.zone == detailZoneActions {
hint = lipgloss.NewStyle().Background(lipgloss.Color("238")).Render(" No actions ")
}
return d.styles.empty.Render(hint)
}
parts := make([]string, 0, len(d.actions))
for i, act := range d.actions {
style := d.styles.action
if d.focused && d.zone == detailZoneActions && i == d.actionIdx {
if act.Danger {
style = d.styles.actionDn
} else {
style = d.styles.actionOn
}
} else if act.Danger {
style = d.styles.actionDn
}
parts = append(parts, style.Render(act.Label))
}
line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" Tab · Enter run")
}
return line
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (d DetailPane) BlinkCmd() tea.Cmd {
return entityFormBlinkCmd()
}
// NewDetailPaneForTest exposes detail pane for tests.
func NewDetailPaneForTest() DetailPane {
return newDetailPane()
}
+60
View File
@@ -0,0 +1,60 @@
package ui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailDocumentScrollByKey(t *testing.T) {
d := newDetailPane()
d.SetSize(50, 20)
longBody := "# Mail\n\n"
for i := 0; i < 80; i++ {
longBody += "line\n"
}
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
d.SetFocused(true)
if d.DocumentYOffset() != 0 {
t.Fatalf("expected top offset 0, got %d", d.DocumentYOffset())
}
if !d.ScrollDocument("down") {
t.Fatal("expected scroll down to succeed")
}
if d.DocumentYOffset() == 0 {
t.Fatal("expected offset after scroll down")
}
if !d.ScrollDocument("up") {
t.Fatal("expected scroll up to succeed")
}
}
func TestDetailDocumentMouseWheel(t *testing.T) {
d := newDetailPane()
d.SetSize(50, 20)
longBody := "# Mail\n\n"
for i := 0; i < 80; i++ {
longBody += "line\n"
}
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
d.ScrollDocumentMouse(tea.MouseMsg{
X: 10, Y: 2, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress,
})
if d.DocumentYOffset() == 0 {
t.Fatal("expected wheel down to scroll document")
}
}
func TestDetailPaneXRange(t *testing.T) {
pw := PaneWidths{
Menu: 12, List: 72, Detail: 36,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
start, end := DetailPaneXRange(pw)
if start != 84 || end != 120 {
t.Fatalf("range=%d..%d want 84..120", start, end)
}
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailTabOrderTaskForm(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "9", Kind: model.KindTask, Title: "T"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Alpha", Secondary: "Beta",
HasTaskStatus: true, TaskStatus: model.TaskLifecycleOpen,
HasResponsible: true, ResponsibleID: "u1",
UserChoices: []model.UserOption{{ID: "u1", Name: "Alice"}},
ProjectTitle: "Proj", TimingSummary: "2026-01-01 → 2026-02-01",
})
stops := []entityField{
entityFieldPrimary, entityFieldSecondary, entityFieldStatus, entityFieldResponsible,
}
for i, want := range stops {
if d.form.field != want {
t.Fatalf("stop %d: field=%d want %d", i, d.form.field, want)
}
if d.TabForward() {
t.Fatalf("tab from stop %d should stay in pane", i)
}
}
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
t.Fatalf("expected Save action, zone=%d idx=%d", d.Zone(), d.actionIdx)
}
if d.TabForward() {
t.Fatal("tab from Save should stay")
}
if d.actionIdx != 1 {
t.Fatalf("expected Close action, idx=%d", d.actionIdx)
}
}
+65
View File
@@ -0,0 +1,65 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailTabOrderProjectForm(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject, Title: "P"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Alpha", Secondary: "Beta", HasStatus: true,
Status: model.ProjectLifecycleOpen,
})
if d.tabStop != 0 || d.form.field != entityFieldPrimary {
t.Fatalf("start on title, tabStop=%d field=%d", d.tabStop, d.form.field)
}
if d.TabForward() {
t.Fatal("tab from title should stay in pane")
}
if d.form.field != entityFieldSecondary {
t.Fatalf("second stop should be description, field=%d", d.form.field)
}
if d.TabForward() {
t.Fatal("tab from description should stay in pane")
}
if d.form.field != entityFieldStatus {
t.Fatalf("third stop should be status, field=%d", d.form.field)
}
if d.TabForward() {
t.Fatal("tab from status should stay in pane")
}
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
t.Fatalf("fourth stop should be Save, zone=%d idx=%d", d.Zone(), d.actionIdx)
}
if d.TabForward() {
t.Fatal("tab from Save should stay in pane")
}
if d.actionIdx != 1 {
t.Fatalf("fifth stop should be Delete, idx=%d", d.actionIdx)
}
if !d.TabForward() {
t.Fatal("tab from Delete should leave pane")
}
if d.tabStop != 0 {
t.Fatalf("after leaving pane tab order resets, tabStop=%d", d.tabStop)
}
}
func TestDetailShiftTabFromTitleLeavesPane(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
})
if !d.TabBackward() {
t.Fatal("shift+tab from title should leave pane")
}
if d.tabStop != d.maxTabStop() {
t.Fatalf("should park on last stop, tabStop=%d max=%d", d.tabStop, d.maxTabStop())
}
}
+49
View File
@@ -0,0 +1,49 @@
package ui_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestEntityFormLoadAndView(t *testing.T) {
form := ui.NewEntityFormForTest()
form.SetSize(40, 16)
form.Load(model.KindTask, "42", model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Fix bug", Secondary: "Details here",
})
view := form.View()
if !strings.Contains(view, "Fix bug") {
t.Fatal("view missing title")
}
}
func TestDetailPaneFormAndActions(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindTask, Title: "T"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Hello", Secondary: "World",
})
view := d.View()
if !strings.Contains(view, "Save") || !strings.Contains(view, "Close") {
t.Fatal("expected Save and Close action buttons")
}
if !strings.Contains(view, "Hello") {
t.Fatal("expected form content")
}
}
func TestDetailPaneDocumentMode(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.LoadDocument(model.Item{ID: "9", Kind: model.KindFile, Title: "a.txt"}, "# Doc\n\nbody", 40)
view := d.View()
if !strings.Contains(view, "Download") {
t.Fatal("expected Download action")
}
}
+558
View File
@@ -0,0 +1,558 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
type entityField int
const (
entityFieldPrimary entityField = iota
entityFieldSecondary
entityFieldStatus
entityFieldResponsible
)
// EntityForm is the top section of the detail pane for editable entities.
type EntityForm struct {
active bool
kind model.Kind
itemID string
primaryLabel string
secondaryLabel string
readOnly bool
hasStatus bool
status model.ProjectLifecycle
hasTaskStatus bool
taskStatus model.TaskLifecycle
hasResponsible bool
responsibleID string
userChoices []model.UserOption
responsibleIdx int
projectTitle string
timingSummary string
hasUserEdit bool
userEnabled bool
userACL model.UserACLState
groupsText string
userFieldIdx int
primary textinput.Model
secondary textarea.Model
password textinput.Model
field entityField
focused bool
dirty bool
width int
height int
styles entityFormStyles
}
type entityFormStyles struct {
header lipgloss.Style
label lipgloss.Style
labelAct lipgloss.Style
meta lipgloss.Style
}
func newEntityFormStyles() entityFormStyles {
return entityFormStyles{
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
}
}
func newEntityForm() EntityForm {
primary := textinput.New()
primary.CharLimit = 512
primary.Prompt = "> "
secondary := textarea.New()
secondary.ShowLineNumbers = false
secondary.CharLimit = 8000
secondary.Prompt = "> "
password := textinput.New()
password.CharLimit = 128
password.EchoMode = textinput.EchoPassword
password.EchoCharacter = '•'
password.Prompt = "> "
return EntityForm{
primary: primary,
secondary: secondary,
password: password,
field: entityFieldPrimary,
styles: newEntityFormStyles(),
}
}
func (f *EntityForm) FieldCount() int {
if f.hasUserEdit {
return f.userFieldCount()
}
n := 2
if f.hasStatus || f.hasTaskStatus {
n++
}
if f.hasResponsible {
n++
}
return n
}
func (f *EntityForm) FormFields() model.FormFields {
return model.FormFields{
PrimaryLabel: f.primaryLabel,
SecondaryLabel: f.secondaryLabel,
Primary: f.primary.Value(),
Secondary: f.secondary.Value(),
ReadOnly: f.readOnly,
HasStatus: f.hasStatus,
Status: f.status,
HasTaskStatus: f.hasTaskStatus,
TaskStatus: f.taskStatus,
HasResponsible: f.hasResponsible,
ResponsibleID: f.responsibleID,
UserChoices: f.userChoices,
ProjectTitle: f.projectTitle,
TimingSummary: f.timingSummary,
HasUserEdit: f.hasUserEdit,
UserEnabled: f.userEnabled,
UserACL: f.userACL,
GroupsText: f.groupsText,
UserPassword: f.password.Value(),
}
}
func (f *EntityForm) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID }
func (f *EntityForm) Primary() string { return f.primary.Value() }
func (f *EntityForm) Secondary() string { return f.secondary.Value() }
func (f *EntityForm) MarkClean() { f.dirty = false }
func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormFields) {
f.active = true
f.kind = kind
f.itemID = itemID
f.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus
f.status = fields.Status
f.hasTaskStatus = fields.HasTaskStatus
f.taskStatus = fields.TaskStatus
f.hasResponsible = fields.HasResponsible
f.userChoices = fields.UserChoices
f.projectTitle = fields.ProjectTitle
f.timingSummary = fields.TimingSummary
f.responsibleID = fields.ResponsibleID
f.responsibleIdx = indexUserChoice(fields.UserChoices, fields.ResponsibleID)
f.hasUserEdit = fields.HasUserEdit
f.userEnabled = fields.UserEnabled
f.userACL = copyUserACLState(fields.UserACL)
f.groupsText = fields.GroupsText
f.userFieldIdx = 0
f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary)
f.password.SetValue("")
f.dirty = false
if f.hasUserEdit {
f.userFieldIdx = 0
} else {
f.field = entityFieldPrimary
}
f.applyFocus()
f.layoutFields()
}
func indexUserChoice(choices []model.UserOption, id string) int {
for i, c := range choices {
if c.ID == id {
return i
}
}
return 0
}
func (f *EntityForm) Clear() {
f.active = false
f.itemID = ""
f.hasStatus = false
f.hasTaskStatus = false
f.hasResponsible = false
f.status = model.ProjectLifecycleOpen
f.taskStatus = model.TaskLifecycleOpen
f.responsibleID = ""
f.userChoices = nil
f.responsibleIdx = 0
f.projectTitle = ""
f.timingSummary = ""
f.hasUserEdit = false
f.userEnabled = false
f.userACL = model.UserACLState{}
f.groupsText = ""
f.userFieldIdx = 0
f.primary.SetValue("")
f.secondary.SetValue("")
f.password.SetValue("")
f.dirty = false
f.primary.Blur()
f.secondary.Blur()
f.password.Blur()
}
func (f *EntityForm) SetFocused(on bool) {
f.focused = on && f.active
if f.focused {
f.applyFocus()
} else {
f.primary.Blur()
f.secondary.Blur()
f.password.Blur()
}
}
func (f *EntityForm) SetSize(w, h int) {
if w < 12 {
w = 12
}
if h < 6 {
h = 6
}
f.width = w
f.height = h
f.layoutFields()
}
func (f *EntityForm) layoutFields() {
if f.width == 0 {
return
}
inner := f.width - 2
if inner < 8 {
inner = 8
}
f.primary.Width = inner
f.password.Width = inner
if f.hasUserEdit {
return
}
metaLines := 0
if f.projectTitle != "" {
metaLines++
}
if f.timingSummary != "" {
metaLines++
}
if metaLines > 0 {
metaLines++ // blank line before meta
}
extra := 6 + metaLines
if f.hasStatus || f.hasTaskStatus {
extra += 2
}
if f.hasResponsible {
extra += 2
}
secH := f.height - extra
if secH < 3 {
secH = 3
}
f.secondary.SetWidth(inner)
f.secondary.SetHeight(secH)
}
func (f *EntityForm) lastField() entityField {
if f.hasResponsible {
return entityFieldResponsible
}
if f.hasStatus || f.hasTaskStatus {
return entityFieldStatus
}
return entityFieldSecondary
}
func (f *EntityForm) FocusNext() {
if f.field < f.lastField() {
f.field++
}
f.applyFocus()
}
func (f *EntityForm) FocusPrev() {
if f.field > entityFieldPrimary {
f.field--
}
f.applyFocus()
}
func (f *EntityForm) SetFieldIndex(i int) {
if f.hasUserEdit {
f.setUserFieldIndex(i)
return
}
switch i {
case 0:
f.field = entityFieldPrimary
case 1:
f.field = entityFieldSecondary
case 2:
if f.hasStatus || f.hasTaskStatus {
f.field = entityFieldStatus
} else if f.hasResponsible {
f.field = entityFieldResponsible
}
case 3:
if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) {
f.field = entityFieldResponsible
}
default:
return
}
f.applyFocus()
}
func (f *EntityForm) cycleStatus(delta int) {
if f.readOnly {
return
}
if f.hasTaskStatus {
if delta > 0 {
f.taskStatus = f.taskStatus.Next()
} else {
f.taskStatus = f.taskStatus.Prev()
}
f.dirty = true
return
}
if !f.hasStatus {
return
}
if delta > 0 {
f.status = f.status.Next()
} else {
f.status = f.status.Prev()
}
f.dirty = true
}
func (f *EntityForm) cycleResponsible(delta int) {
if !f.hasResponsible || f.readOnly || len(f.userChoices) == 0 {
return
}
n := len(f.userChoices)
f.responsibleIdx = (f.responsibleIdx + delta%n + n) % n
f.responsibleID = f.userChoices[f.responsibleIdx].ID
f.dirty = true
}
func (f *EntityForm) responsibleLabel() string {
if f.responsibleID == "" {
return "—"
}
for _, c := range f.userChoices {
if c.ID == f.responsibleID {
if c.Name != "" {
return c.Name
}
return c.ID
}
}
return f.responsibleID
}
func (f *EntityForm) applyFocus() {
if f.hasUserEdit {
f.applyUserFocus()
return
}
if f.readOnly {
f.primary.Blur()
f.secondary.Blur()
return
}
switch f.field {
case entityFieldPrimary:
f.primary.Focus()
f.secondary.Blur()
case entityFieldSecondary:
f.primary.Blur()
f.secondary.Focus()
default:
f.primary.Blur()
f.secondary.Blur()
}
}
func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
if f.hasUserEdit {
return f.updateUserForm(msg)
}
if key, ok := msg.(tea.KeyMsg); ok {
switch f.field {
case entityFieldStatus:
switch key.String() {
case "left", "h":
f.cycleStatus(-1)
return nil
case "right", "l":
f.cycleStatus(1)
return nil
case " ":
f.cycleStatus(1)
return nil
}
case entityFieldResponsible:
switch key.String() {
case "left", "h":
f.cycleResponsible(-1)
return nil
case "right", "l":
f.cycleResponsible(1)
return nil
case " ":
f.cycleResponsible(1)
return nil
}
}
}
var cmd tea.Cmd
switch f.field {
case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg)
case entityFieldSecondary:
f.secondary, cmd = f.secondary.Update(msg)
default:
return nil
}
if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true
}
return cmd
}
func (f EntityForm) View() string {
if !f.active {
return ""
}
if f.hasUserEdit {
return f.userView()
}
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
pLabel := f.styles.label.Render(f.primaryLabel)
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
pLabel = f.styles.labelAct.Render(f.primaryLabel)
}
sLabel := f.styles.label.Render(f.secondaryLabel)
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
statusBlock := f.renderStatusBlock()
responsibleBlock := f.renderResponsibleBlock()
metaBlock := f.renderMetaBlock()
ro := ""
if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
}
parts := []string{
header + ro,
pLabel,
f.primary.View(),
"",
sLabel,
f.secondary.View(),
}
if statusBlock != "" {
parts = append(parts, statusBlock)
}
if responsibleBlock != "" {
parts = append(parts, responsibleBlock)
}
if metaBlock != "" {
parts = append(parts, metaBlock)
}
return strings.Join(parts, "\n")
}
func (f EntityForm) renderStatusBlock() string {
if !f.hasStatus && !f.hasTaskStatus {
return ""
}
stLabel := f.styles.label.Render("Status")
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
label := f.status.Label()
valStyle := f.styles.label
if f.hasTaskStatus {
label = f.taskStatus.Label()
if f.taskStatus == model.TaskLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else if f.taskStatus == model.TaskLifecycleOpen {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
} else if f.status == model.ProjectLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.field == entityFieldStatus && !f.readOnly {
hint = f.styles.label.Render(" ←/→ cycle")
}
return strings.Join([]string{"", stLabel, valStyle.Render(label) + hint}, "\n")
}
func (f EntityForm) renderResponsibleBlock() string {
if !f.hasResponsible {
return ""
}
rLabel := f.styles.label.Render("Responsible")
if f.focused && f.field == entityFieldResponsible && !f.readOnly {
rLabel = f.styles.labelAct.Render("Responsible")
}
hint := ""
if f.focused && f.field == entityFieldResponsible && !f.readOnly && len(f.userChoices) > 0 {
hint = f.styles.label.Render(" ←/→ choose")
}
return strings.Join([]string{
"",
rLabel,
f.styles.meta.Render(f.responsibleLabel()) + hint,
}, "\n")
}
func (f EntityForm) renderMetaBlock() string {
if f.projectTitle == "" && f.timingSummary == "" {
return ""
}
lines := []string{""}
if f.projectTitle != "" {
lines = append(lines, f.styles.label.Render("Project"), f.styles.meta.Render(f.projectTitle))
}
if f.timingSummary != "" {
lines = append(lines, f.styles.label.Render("Timing"), f.styles.meta.Render(f.timingSummary))
}
return strings.Join(lines, "\n")
}
func entityFormBlinkCmd() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink)
}
// NewEntityFormForTest exposes a form for unit tests.
func NewEntityFormForTest() EntityForm {
return newEntityForm()
}
+161
View File
@@ -0,0 +1,161 @@
package ui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (f *EntityForm) userFieldCount() int {
return 2 + len(model.UserACLDefs)
}
func (f *EntityForm) userView() string {
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
lines := []string{
header,
f.styles.label.Render("ID"),
f.styles.meta.Render(f.itemID),
"",
f.renderUserEnabledRow(),
"",
f.renderUserPasswordRow(),
}
for i, def := range model.UserACLDefs {
lines = append(lines, f.renderUserACLRow(i, def.Label, f.userACL.ACLModuleOn(def.Key)))
}
lines = append(lines, "", f.styles.label.Render("Groups"), f.styles.meta.Render(f.groupsText))
return strings.Join(lines, "\n")
}
func (f *EntityForm) renderUserEnabledRow() string {
label := f.styles.label.Render("Account")
if f.focused && f.userFieldIdx == 0 {
label = f.styles.labelAct.Render("Account")
}
val := "Disabled"
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
if f.userEnabled {
val = "Enabled"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.userFieldIdx == 0 {
hint = f.styles.label.Render(" space toggle")
}
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
}
func (f *EntityForm) renderUserPasswordRow() string {
label := f.styles.label.Render("Password")
if f.focused && f.userFieldIdx == 1 {
label = f.styles.labelAct.Render("Password")
}
return strings.Join([]string{label, f.password.View(), f.styles.meta.Render("(leave blank to keep)")}, "\n")
}
func (f *EntityForm) renderUserACLRow(idx int, title string, on bool) string {
fieldIdx := idx + 2
label := f.styles.label.Render(title)
if f.focused && f.userFieldIdx == fieldIdx {
label = f.styles.labelAct.Render(title)
}
val := "Off"
style := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
if on {
val = "On"
style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.userFieldIdx == fieldIdx {
hint = f.styles.label.Render(" space toggle")
}
return strings.Join([]string{label, style.Render(val) + hint}, "\n")
}
func (f *EntityForm) setUserFieldIndex(i int) {
if i < 0 {
i = 0
}
max := f.userFieldCount() - 1
if i > max {
i = max
}
f.userFieldIdx = i
f.applyFocus()
}
func (f *EntityForm) toggleUserEnabled() {
f.userEnabled = !f.userEnabled
f.dirty = true
}
func (f *EntityForm) toggleUserACL(idx int) {
if idx < 0 || idx >= len(model.UserACLDefs) {
return
}
if idx == 0 {
f.userACL.FullAccess = !f.userACL.FullAccess
if f.userACL.FullAccess {
for k := range f.userACL.Modules {
f.userACL.Modules[k] = true
}
}
f.dirty = true
return
}
def := model.UserACLDefs[idx]
if f.userACL.FullAccess {
f.userACL.FullAccess = false
for k := range f.userACL.Modules {
f.userACL.Modules[k] = true
}
}
f.userACL.Modules[def.Key] = !f.userACL.Modules[def.Key]
f.dirty = true
}
func (f *EntityForm) updateUserForm(msg tea.Msg) tea.Cmd {
if key, ok := msg.(tea.KeyMsg); ok {
switch f.userFieldIdx {
case 0:
switch key.String() {
case " ", "left", "right", "l", "h":
f.toggleUserEnabled()
return nil
}
case 1:
var cmd tea.Cmd
f.password, cmd = f.password.Update(msg)
f.dirty = true
return cmd
default:
switch key.String() {
case " ", "left", "right", "l", "h":
f.toggleUserACL(f.userFieldIdx - 2)
return nil
}
}
}
return nil
}
func (f *EntityForm) applyUserFocus() {
f.primary.Blur()
f.secondary.Blur()
if f.userFieldIdx == 1 {
f.password.Focus()
} else {
f.password.Blur()
}
}
func copyUserACLState(s model.UserACLState) model.UserACLState {
modules := make(map[string]bool, len(s.Modules))
for k, v := range s.Modules {
modules[k] = v
}
return model.UserACLState{FullAccess: s.FullAccess, Modules: modules}
}
+61
View File
@@ -0,0 +1,61 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestUserFormViewShowsIDAndACL(t *testing.T) {
f := NewEntityFormForTest()
f.SetSize(60, 30)
f.Load(model.KindUser, "42", model.FormFields{
HasUserEdit: true,
UserEnabled: true,
UserACL: model.UserACLState{
FullAccess: false,
Modules: map[string]bool{
"documents": true,
},
},
GroupsText: "Admins, Devs",
})
view := f.View()
for _, want := range []string{"User 42", "ID", "42", "Account", "Password", "Full access", "Documents", "Groups", "Admins"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q:\n%s", want, view)
}
}
}
func TestUserFormFieldCount(t *testing.T) {
f := NewEntityFormForTest()
f.Load(model.KindUser, "1", model.FormFields{HasUserEdit: true, UserACL: model.UserACLFromRaw(nil)})
if got := f.FieldCount(); got != 2+len(model.UserACLDefs) {
t.Fatalf("field count=%d want %d", got, len(model.UserACLDefs)+2)
}
}
func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectUsers, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
if !ok {
t.Fatal("expected flex layout")
}
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
sum := 0
emailW := 0
for _, i := range lay.indices {
sum += lay.widths[i]
if cols[i].Key == "email" {
emailW = lay.widths[i]
}
}
if sum != 100 {
t.Fatalf("sum=%d want 100", sum)
}
if emailW < 30 {
t.Fatalf("email width=%d should absorb extra space", emailW)
}
}
+99
View File
@@ -0,0 +1,99 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FilterSearch is the right-pane query input for nav/list filtering.
type FilterSearch struct {
input textinput.Model
width int
height int
focused bool
styles filterSearchStyles
}
type filterSearchStyles struct {
title lipgloss.Style
hint lipgloss.Style
}
func newFilterSearch() FilterSearch {
in := textinput.New()
in.Prompt = "/ "
in.Placeholder = "Filter list…"
in.CharLimit = 256
return FilterSearch{
input: in,
styles: filterSearchStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
},
}
}
func (f *FilterSearch) Query() string { return f.input.Value() }
func (f *FilterSearch) SetFocused(on bool) {
f.focused = on
if on {
f.input.Focus()
} else {
f.input.Blur()
}
}
func (f *FilterSearch) SetSize(w, h int) {
if w < 12 {
w = 12
}
if h < 6 {
h = 6
}
f.width = w
f.height = h
inner := w - 2
if inner < 8 {
inner = 8
}
f.input.Width = inner
}
func (f *FilterSearch) Clear() {
f.input.SetValue("")
}
func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
if !f.focused {
return nil
}
var cmd tea.Cmd
f.input, cmd = f.input.Update(msg)
return cmd
}
func (f *FilterSearch) SetInputWidth(inner int) {
if inner < 8 {
inner = 8
}
f.input.Width = inner
}
func (f *FilterSearch) CompactView() string {
return f.input.View()
}
func (f FilterSearch) View() string {
title := f.styles.title.Render("Filter")
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
hint := f.styles.hint.Render("Filters left nav and center table · Esc clear · f focus")
return strings.Join([]string{title, "", body, "", hint}, "\n")
}
func filterSearchBlinkCmd() tea.Cmd {
return textinput.Blink
}
+93
View File
@@ -0,0 +1,93 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// Action is a keyboard command outcome for the TUI.
type Action int
const (
ActionNone Action = iota
ActionMoveUp
ActionMoveDown
ActionMoveLeft
ActionMoveRight
ActionSort
ActionToggleSelect
ActionFocusDetail
ActionToggleMenuPane
ActionToggleListPane
ActionToggleDetailPane
ActionFilter
ActionNextPane
ActionPrevPane
ActionRefresh
ActionQuit
)
// KeyAction maps a key string and focused pane to an action.
func KeyAction(key string, pane model.FocusPane) Action {
switch key {
case "q", "ctrl+c":
return ActionQuit
case "tab":
if pane != model.FocusPreview {
return ActionNextPane
}
case "shift+tab", "backtab":
if pane != model.FocusPreview {
return ActionPrevPane
}
case "r":
return ActionRefresh
case "f", "/":
if pane == model.FocusList {
return ActionFilter
}
if pane == model.FocusMenu {
return ActionFilter
}
case "up", "k":
return ActionMoveUp
case "down", "j":
return ActionMoveDown
case "s":
if pane == model.FocusList {
return ActionSort
}
case "left", "h":
return ActionMoveLeft
case "right", "l":
return ActionMoveRight
case " ":
if pane == model.FocusList {
return ActionToggleSelect
}
case "v", "p":
if pane == model.FocusList {
return ActionFocusDetail
}
case "alt+1":
return ActionToggleMenuPane
case "alt+2":
return ActionToggleListPane
case "alt+3":
return ActionToggleDetailPane
}
return ActionNone
}
// LayoutWidthsLegacy is kept for tests that expect the old signature.
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
pw := LayoutWidths(total, defaultPaneVisibility())
return pw.Menu, pw.List, pw.Detail
}
// ResolveMoveUp returns the action for upward navigation keys.
func ResolveMoveUp(pane model.FocusPane) Action {
switch pane {
case model.FocusMenu, model.FocusList, model.FocusPreview:
return ActionMoveUp
default:
return ActionNone
}
}
+145
View File
@@ -0,0 +1,145 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// PaneVisibility controls which columns are shown.
type PaneVisibility struct {
Menu, List, Detail bool
}
// PaneWidths is outer lipgloss width per column (sums to terminal width).
type PaneWidths struct {
Menu, List, Detail int
Visibility PaneVisibility
}
func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true}
}
const (
defaultMenuShare = 10
defaultListShare = 60
defaultDetailShare = 30
)
// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%).
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 {
total = 80
}
v := vis
if countVisible(v) == 0 {
v = defaultPaneVisibility()
}
shareSum := 0
if v.Menu {
shareSum += defaultMenuShare
}
if v.List {
shareSum += defaultListShare
}
if v.Detail {
shareSum += defaultDetailShare
}
assign := func(visible bool, share int) int {
if !visible || shareSum == 0 {
return 0
}
return total * share / shareSum
}
out := PaneWidths{Visibility: v}
out.Menu = assign(v.Menu, defaultMenuShare)
out.List = assign(v.List, defaultListShare)
out.Detail = assign(v.Detail, defaultDetailShare)
fixPaneWidthSum(&out, total)
return out
}
// DetailPaneXRange returns the [start, end) column span of the detail pane.
func DetailPaneXRange(pw PaneWidths) (start, end int) {
if !pw.Visibility.Detail {
return 0, 0
}
if pw.Visibility.Menu {
start += pw.Menu
}
if pw.Visibility.List {
start += pw.List
}
end = start + pw.Detail
return start, end
}
func countVisible(v PaneVisibility) int {
n := 0
if v.Menu {
n++
}
if v.List {
n++
}
if v.Detail {
n++
}
return n
}
// NextVisibleFocus cycles focus forward, skipping hidden panes.
func NextVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start+step)%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
// PrevVisibleFocus cycles focus backward, skipping hidden panes.
func PrevVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start-step+len(order))%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
func paneVisible(p model.FocusPane, vis PaneVisibility) bool {
switch p {
case model.FocusMenu:
return vis.Menu
case model.FocusList:
return vis.List
default:
return vis.Detail
}
}
func firstVisibleFocus(vis PaneVisibility) model.FocusPane {
if vis.Menu {
return model.FocusMenu
}
if vis.List {
return model.FocusList
}
return model.FocusPreview
}
+203
View File
@@ -0,0 +1,203 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
const (
minPaneOuterWidth = 14
resizeHitSlop = 1
)
type paneResizeState struct {
active bool
divider int
anchorX int
anchorPW PaneWidths
}
// FitPaneWidths scales stored pane widths to fill total width for visible panes.
func FitPaneWidths(total int, vis PaneVisibility, sizes PaneWidths) PaneWidths {
if total < 1 {
total = 80
}
out := PaneWidths{Visibility: vis}
if countVisible(vis) == 0 {
return LayoutWidths(total, vis)
}
sum := 0
if vis.Menu {
sum += sizes.Menu
}
if vis.List {
sum += sizes.List
}
if vis.Detail {
sum += sizes.Detail
}
if sum <= 0 {
return LayoutWidths(total, vis)
}
assign := func(visible bool, stored int) int {
if !visible {
return 0
}
return stored * total / sum
}
out.Menu = assign(vis.Menu, sizes.Menu)
out.List = assign(vis.List, sizes.List)
out.Detail = assign(vis.Detail, sizes.Detail)
fixPaneWidthSum(&out, total)
return out
}
func fixPaneWidthSum(pw *PaneWidths, total int) {
if pw == nil || total < 1 {
return
}
type slot struct {
visible bool
width *int
}
slots := []slot{
{pw.Visibility.Menu, &pw.Menu},
{pw.Visibility.List, &pw.List},
{pw.Visibility.Detail, &pw.Detail},
}
sum := 0
var last *int
for _, s := range slots {
if !s.visible {
continue
}
sum += *s.width
last = s.width
}
if last == nil {
return
}
*last += total - sum
if *last < minPaneOuterWidth {
*last = minPaneOuterWidth
}
}
// DividerPositions returns x coordinates of vertical pane separators.
func DividerPositions(pw PaneWidths) []int {
var out []int
x := 0
if pw.Visibility.Menu {
x += pw.Menu
if pw.Visibility.List || pw.Visibility.Detail {
out = append(out, x)
}
}
if pw.Visibility.List {
x += pw.List
if pw.Visibility.Detail {
out = append(out, x)
}
}
return out
}
// DividerAt returns the divider index under x, or -1.
func DividerAt(x int, pw PaneWidths) int {
for i, pos := range DividerPositions(pw) {
if x >= pos-resizeHitSlop && x <= pos+resizeHitSlop {
return i
}
}
return -1
}
// DragPaneDivider adjusts adjacent pane widths by deltaX pixels.
func DragPaneDivider(pw PaneWidths, divider, deltaX int) (PaneWidths, bool) {
if deltaX == 0 {
return pw, false
}
changed := false
switch divider {
case 0:
if pw.Visibility.Menu && pw.Visibility.List {
if resizePair(&pw.Menu, &pw.List, deltaX) {
changed = true
}
} else if pw.Visibility.Menu && pw.Visibility.Detail {
if resizePair(&pw.Menu, &pw.Detail, deltaX) {
changed = true
}
}
case 1:
if pw.Visibility.List && pw.Visibility.Detail {
if resizePair(&pw.List, &pw.Detail, deltaX) {
changed = true
}
}
}
return pw, changed
}
func resizePair(left, right *int, delta int) bool {
if left == nil || right == nil {
return false
}
newLeft := *left + delta
newRight := *right - delta
if newLeft < minPaneOuterWidth || newRight < minPaneOuterWidth {
return false
}
*left = newLeft
*right = newRight
return true
}
func (m Model) paneResizeMaxY() int {
return m.paneHeight() + 1
}
func (m *Model) handlePaneResizeMouse(msg tea.MouseMsg) bool {
if msg.Y >= m.paneResizeMaxY() {
if m.resize.active && msg.Action == tea.MouseActionRelease {
m.resize.active = false
return true
}
return false
}
pw := m.paneLayout()
switch msg.Action {
case tea.MouseActionPress:
if msg.Button != tea.MouseButtonLeft {
return false
}
if div := DividerAt(msg.X, pw); div >= 0 {
m.paneSizes = pw
m.customPaneLayout = true
m.resize = paneResizeState{
active: true,
divider: div,
anchorX: msg.X,
anchorPW: pw,
}
return true
}
case tea.MouseActionRelease:
if m.resize.active {
m.resize.active = false
return true
}
case tea.MouseActionMotion:
if m.resize.active {
delta := msg.X - m.resize.anchorX
next, ok := DragPaneDivider(m.resize.anchorPW, m.resize.divider, delta)
if ok {
m.paneSizes = next
m.layoutViewports()
m.syncAllContent()
}
return true
}
}
return m.resize.active
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import "testing"
func TestDividerAtBetweenMenuAndList(t *testing.T) {
pw := PaneWidths{
Menu: 30, List: 50, Detail: 40,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
if got := DividerAt(30, pw); got != 0 {
t.Fatalf("divider at 30=%d want 0", got)
}
if got := DividerAt(80, pw); got != 1 {
t.Fatalf("divider at 80=%d want 1", got)
}
}
func TestDragPaneDividerRespectsMinimum(t *testing.T) {
pw := PaneWidths{
Menu: 40, List: 40, Detail: 40,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
next, ok := DragPaneDivider(pw, 0, -30)
if ok {
t.Fatalf("expected resize blocked by minimum, got %+v", next)
}
next, ok = DragPaneDivider(pw, 0, 5)
if !ok || next.Menu != 45 || next.List != 35 {
t.Fatalf("resize failed: ok=%v %+v", ok, next)
}
}
func TestFitPaneWidthsScalesToTotal(t *testing.T) {
vis := PaneVisibility{Menu: true, List: true, Detail: true}
sizes := PaneWidths{Menu: 20, List: 30, Detail: 50, Visibility: vis}
out := FitPaneWidths(120, vis, sizes)
sum := out.Menu + out.List + out.Detail
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
}
+138
View File
@@ -0,0 +1,138 @@
package ui
import (
"regexp"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
)
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
vis := PaneVisibility{Menu: true, List: true, Detail: true}
pw := LayoutWidths(120, vis)
sum := pw.Menu + pw.List + pw.Detail
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 {
t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail)
}
}
func TestLayoutWidthsSinglePane(t *testing.T) {
pw := LayoutWidths(100, PaneVisibility{Menu: false, List: true, Detail: false})
if pw.List != 100 {
t.Fatalf("list=%d want 100", pw.List)
}
if pw.Menu != 0 || pw.Detail != 0 {
t.Fatalf("unexpected widths: %+v", pw)
}
}
func TestLayoutWidthsTwoPanes(t *testing.T) {
pw := LayoutWidths(80, PaneVisibility{Menu: true, List: false, Detail: true})
if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
}
if pw.Menu != 20 || pw.Detail != 60 {
t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail)
}
}
func TestDetailPaneXRangeSkipsHidden(t *testing.T) {
pw := PaneWidths{
Menu: 20, List: 60, Detail: 0,
Visibility: PaneVisibility{Menu: true, List: true, Detail: false},
}
start, end := DetailPaneXRange(pw)
if start != 0 || end != 0 {
t.Fatalf("hidden detail should have empty range, got %d..%d", start, end)
}
}
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: false, List: true, Detail: true}
if got := NextVisibleFocus(model.FocusList, vis); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
if got := NextVisibleFocus(model.FocusPreview, vis); got != model.FocusList {
t.Fatalf("got %v", got)
}
}
func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: true, List: false, Detail: true}
if got := PrevVisibleFocus(model.FocusPreview, vis); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
}
func TestThreePaneRenderedWidthMatchesTerminal(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
total := 120
pw := LayoutWidths(total, PaneVisibility{Menu: true, List: true, Detail: true})
h := 12
menu := paneStyle(false).Width(paneLipglossWidth(pw.Menu)).Height(h).Render(strings.Repeat("m", paneContentWidth(pw.Menu)))
inner := paneContentWidth(pw.List)
tbl := newDataTable()
tbl.SetSize(inner, h-listToolbarHeight)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
toolbar := newListToolbar()
toolbar.SetWidth(inner)
body := lipgloss.JoinVertical(lipgloss.Left, toolbar.View(ListToolbarMeta{Subject: "projects", Count: 1}), tbl.View())
list := paneStyle(true).Width(paneLipglossWidth(pw.List)).Height(h).Render(body)
detail := paneStyle(false).Width(paneLipglossWidth(pw.Detail)).Height(h).Render(strings.Repeat("d", paneContentWidth(pw.Detail)))
full := lipgloss.JoinHorizontal(lipgloss.Top, menu, list, detail)
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
top := strings.Split(full, "\n")[0]
got := runewidth.StringWidth(re.ReplaceAllString(top, ""))
if got != total {
t.Fatalf("top border width=%d want %d", got, total)
}
for _, line := range strings.Split(tbl.View(), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
w := runewidth.StringWidth(re.ReplaceAllString(line, ""))
if w > inner+1 {
t.Fatalf("table line width=%d want <=%d", w, inner+1)
}
}
}
func TestPaneContentWidth(t *testing.T) {
if got := paneContentWidth(72); got != 68 {
t.Fatalf("content width=%d want 68", got)
}
if got := paneLipglossWidth(72); got != 70 {
t.Fatalf("lipgloss width=%d want 70", got)
}
}
func TestLayoutWidthsLegacyHelper(t *testing.T) {
menu, list, preview := LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
t.Fatalf("widths exceed total")
}
}
func TestKeyActionTogglePanes(t *testing.T) {
if got := KeyAction("alt+1", model.FocusList); got != ActionToggleMenuPane {
t.Fatalf("got %v", got)
}
if got := KeyAction("alt+2", model.FocusMenu); got != ActionToggleListPane {
t.Fatalf("got %v", got)
}
if got := KeyAction("alt+3", model.FocusList); got != ActionToggleDetailPane {
t.Fatalf("got %v", got)
}
}
+352
View File
@@ -0,0 +1,352 @@
package ui
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
const listToolbarHeight = 2
type listZone int
const (
listZoneTable listZone = iota
listZoneFilter
listZoneActions
)
// ListToolbarMeta is the navigation/sort summary shown above the filter row.
type ListToolbarMeta struct {
Subject string
Count int
SortLabel string
LoadingMore bool
SaveEnabled bool
DeleteEnabled bool
}
// ListToolbar is the filter & navigation bar above the center table.
type ListToolbar struct {
filter FilterSearch
width int
zone listZone
actionIdx int
actionRects []toolbarBtnRect
btnX0 int
styles listToolbarStyles
}
type toolbarBtnRect struct {
action model.ActionID
x0, x1 int
}
type listToolbarStyles struct {
title lipgloss.Style
btn lipgloss.Style
btnOn lipgloss.Style
btnOff lipgloss.Style
btnAct lipgloss.Style
btnDn lipgloss.Style
}
func newListToolbar() ListToolbar {
return ListToolbar{
filter: newFilterSearch(),
styles: listToolbarStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
btn: lipgloss.NewStyle().Padding(0, 1),
btnOn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")),
btnOff: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("238")),
btnAct: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
btnDn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
},
}
}
func (b *ListToolbar) SetWidth(w int) {
if w < 12 {
w = 12
}
b.width = w
inner := w - 8
if inner < 12 {
inner = 12
}
b.filter.SetInputWidth(inner)
}
func (b *ListToolbar) Zone() listZone { return b.zone }
func (b *ListToolbar) SetZone(z listZone) { b.zone = z }
func (b *ListToolbar) Query() string { return b.filter.Query() }
func (b *ListToolbar) ClearFilter() { b.filter.Clear() }
func (b *ListToolbar) syncInputFocus() {
b.filter.SetFocused(b.zone == listZoneFilter)
}
func (b *ListToolbar) FocusFilter() {
b.zone = listZoneFilter
b.filter.SetFocused(true)
}
func (b *ListToolbar) FocusTable() {
b.zone = listZoneTable
b.filter.SetFocused(false)
}
func (b *ListToolbar) TabForward(meta ListToolbarMeta) {
switch b.zone {
case listZoneTable:
b.FocusFilter()
case listZoneFilter:
b.zone = listZoneActions
b.actionIdx = 0
b.filter.SetFocused(false)
b.clampActionIdx(meta)
case listZoneActions:
if b.nextEnabledAction(meta, 1) {
return
}
b.FocusTable()
}
}
func (b *ListToolbar) TabBackward(meta ListToolbarMeta) {
switch b.zone {
case listZoneTable:
b.zone = listZoneActions
b.actionIdx = b.lastEnabledAction(meta)
b.filter.SetFocused(false)
case listZoneActions:
if b.nextEnabledAction(meta, -1) {
return
}
b.FocusFilter()
case listZoneFilter:
b.FocusTable()
}
}
func (b *ListToolbar) toolbarActions(meta ListToolbarMeta) []model.ActionID {
var out []model.ActionID
if meta.SaveEnabled {
out = append(out, model.ActionSave)
}
if meta.DeleteEnabled {
out = append(out, model.ActionDelete)
}
return out
}
func (b *ListToolbar) clampActionIdx(meta ListToolbarMeta) {
acts := b.toolbarActions(meta)
if len(acts) == 0 {
b.actionIdx = 0
return
}
if b.actionIdx >= len(acts) {
b.actionIdx = len(acts) - 1
}
if b.actionIdx < 0 {
b.actionIdx = 0
}
}
func (b *ListToolbar) lastEnabledAction(meta ListToolbarMeta) int {
acts := b.toolbarActions(meta)
if len(acts) == 0 {
return 0
}
return len(acts) - 1
}
func (b *ListToolbar) nextEnabledAction(meta ListToolbarMeta, delta int) bool {
acts := b.toolbarActions(meta)
if len(acts) == 0 {
return false
}
next := b.actionIdx + delta
if next >= 0 && next < len(acts) {
b.actionIdx = next
return true
}
return false
}
func (b *ListToolbar) SelectedAction(meta ListToolbarMeta) (model.ActionID, bool) {
acts := b.toolbarActions(meta)
if b.zone != listZoneActions || len(acts) == 0 {
return "", false
}
b.clampActionIdx(meta)
return acts[b.actionIdx], true
}
func (b *ListToolbar) MoveAction(delta int, meta ListToolbarMeta) {
if b.zone != listZoneActions {
return
}
b.nextEnabledAction(meta, delta)
}
func (b *ListToolbar) Update(msg tea.Msg, meta ListToolbarMeta) tea.Cmd {
if b.zone == listZoneFilter {
if key, ok := msg.(tea.KeyMsg); ok {
cmd := b.filter.Update(msg)
switch key.String() {
case "tab":
b.TabForward(meta)
return cmd
case "shift+tab", "backtab":
b.TabBackward(meta)
return cmd
case "enter":
b.zone = listZoneActions
b.actionIdx = 0
b.filter.SetFocused(false)
b.clampActionIdx(meta)
return cmd
}
return cmd
}
return b.filter.Update(msg)
}
if b.zone == listZoneActions {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "left", "h":
b.MoveAction(-1, meta)
case "right", "l":
b.MoveAction(1, meta)
case "tab":
b.TabForward(meta)
case "shift+tab", "backtab":
b.TabBackward(meta)
}
}
}
return nil
}
func (b *ListToolbar) View(meta ListToolbarMeta) string {
title := b.styles.title.Render(meta.Subject)
if meta.Count > 0 || meta.Subject != "" {
title = b.styles.title.Render(formatListToolbarTitle(meta))
}
if meta.LoadingMore {
title += lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" …")
}
filter := b.filter.CompactView()
buttons, rects := b.renderButtons(meta)
avail := b.width - runewidth.StringWidth(buttons) - 1
if avail < 8 {
avail = 8
}
b.btnX0 = avail + 1
for i := range rects {
rects[i].x0 += b.btnX0
rects[i].x1 += b.btnX0
}
b.actionRects = rects
filterLine := padDisplayWidth(filter, avail) + " " + buttons
if runewidth.StringWidth(filterLine) > b.width {
filterLine = runewidth.Truncate(filterLine, b.width, "")
}
lines := []string{
padANSIWidth(title, b.width),
padANSIWidth(filterLine, b.width),
}
return lipgloss.NewStyle().Width(b.width).MaxWidth(b.width).Render(strings.Join(lines, "\n"))
}
func formatListToolbarTitle(meta ListToolbarMeta) string {
s := meta.Subject
if meta.Count > 0 {
s = fmt.Sprintf("%s (%d)", meta.Subject, meta.Count)
}
if meta.SortLabel != "" {
s += " " + meta.SortLabel
}
return s
}
func (b *ListToolbar) renderButtons(meta ListToolbarMeta) (string, []toolbarBtnRect) {
type btn struct {
action model.ActionID
icon string
enabled bool
danger bool
}
defs := []btn{
{model.ActionSave, "💾", meta.SaveEnabled, false},
{model.ActionDelete, "🗑", meta.DeleteEnabled, true},
}
acts := b.toolbarActions(meta)
rects := make([]toolbarBtnRect, 0, len(defs))
parts := make([]string, 0, len(defs))
x := 0
for i, d := range defs {
active := b.zone == listZoneActions && len(acts) > 0 && b.actionIdx < len(acts) && acts[b.actionIdx] == d.action
var style lipgloss.Style
switch {
case !d.enabled:
style = b.styles.btnOff
case active && d.danger:
style = b.styles.btnDn
case active:
style = b.styles.btnAct
case d.danger:
style = b.styles.btn.Foreground(lipgloss.Color("203"))
default:
style = b.styles.btnOn
}
rendered := style.Render(d.icon)
w := runewidth.StringWidth(rendered)
rects = append(rects, toolbarBtnRect{action: d.action, x0: x, x1: x + w})
x += w
parts = append(parts, rendered)
_ = i
}
return strings.Join(parts, " "), rects
}
func (b *ListToolbar) ActionAt(x int, meta ListToolbarMeta) (model.ActionID, bool) {
_ = meta
if x < 0 {
return "", false
}
for _, r := range b.actionRects {
if x >= r.x0 && x < r.x1 {
action := r.action
if b.IsActionEnabled(action, meta) {
return action, true
}
return "", false
}
}
return "", false
}
func (b *ListToolbar) IsActionEnabled(action model.ActionID, meta ListToolbarMeta) bool {
switch action {
case model.ActionSave:
return meta.SaveEnabled
case model.ActionDelete:
return meta.DeleteEnabled
default:
return false
}
}
+58
View File
@@ -0,0 +1,58 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestListToolbarShowsFilterAndActions(t *testing.T) {
b := newListToolbar()
b.SetWidth(60)
view := b.View(ListToolbarMeta{
Subject: "projects",
Count: 3,
})
if !strings.Contains(view, "projects (3)") {
t.Fatalf("missing subject: %q", view)
}
if !strings.Contains(view, "💾") || !strings.Contains(view, "🗑") {
t.Fatalf("missing action icons: %q", view)
}
}
func TestListToolbarActionsEnabledFromMeta(t *testing.T) {
b := newListToolbar()
on := ListToolbarMeta{SaveEnabled: true, DeleteEnabled: true}
off := ListToolbarMeta{}
if !b.IsActionEnabled(model.ActionSave, on) {
t.Fatal("save should be enabled")
}
if b.IsActionEnabled(model.ActionSave, off) {
t.Fatal("save should be disabled")
}
if !b.IsActionEnabled(model.ActionDelete, on) {
t.Fatal("delete should be enabled")
}
}
func TestCanSaveFromListRequiresSelection(t *testing.T) {
m := Model{
items: []model.Item{
{ID: "1", Title: "A", Kind: model.KindProject, Selected: true},
},
}
if m.canSaveFromList() {
t.Fatal("save should require matching loaded detail")
}
m.detail = newDetailPane()
m.detail.SetSize(40, 20)
m.detail.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "A", HasStatus: true,
})
if !m.canSaveFromList() {
t.Fatal("save should be enabled with one selected row and loaded detail")
}
}
+100
View File
@@ -0,0 +1,100 @@
package ui
import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (m *Model) scrollFocusedPane(key string) bool {
switch m.focus {
case model.FocusMenu:
switch key {
case "pgdown", "pgdn", "ctrl+d", "pgup", "b", "ctrl+u", "home", "g", "end", "G":
return scrollViewport(&m.menuVP, key)
}
return false
case model.FocusList:
switch key {
case "pgdown", "pgdn", "ctrl+d":
m.listTable.PageScroll(1)
return true
case "pgup", "b", "ctrl+u":
m.listTable.PageScroll(-1)
return true
}
return false
case model.FocusPreview:
if m.detail.Zone() != detailZoneContent {
return false
}
return m.detail.ScrollDocument(key)
default:
return false
}
}
func (m *Model) paneHeight() int {
h := m.height - 3
if h < 3 {
h = 3
}
return h
}
const (
paneBorderChars = 2 // left + right border drawn outside lipgloss Width
panePaddingChars = 2 // Padding(0, 1) on paneStyle
)
// paneLipglossWidth maps an on-screen pane width to lipgloss Style.Width.
// Bordered panes render two cells wider than the Width value.
func paneLipglossWidth(rendered int) int {
if rendered <= 0 {
return 8
}
w := rendered - paneBorderChars
if w < 8 {
w = 8
}
return w
}
// paneContentWidth is the usable inner width for viewports and tables.
func paneContentWidth(rendered int) int {
if rendered <= 0 {
return 8
}
w := rendered - paneBorderChars - panePaddingChars
if w < 8 {
w = 8
}
return w
}
func (m *Model) paneInnerWidth(rendered int) int {
return paneContentWidth(rendered)
}
func scrollViewport(vp *viewport.Model, key string) bool {
switch key {
case "up", "k":
vp.LineUp(1)
return true
case "down", "j":
vp.LineDown(1)
return true
case "pgdown", "pgdn", "ctrl+d":
vp.ViewDown()
return true
case "pgup", "b", "ctrl+u":
vp.ViewUp()
return true
case "home", "g":
vp.GotoTop()
return true
case "end", "G":
vp.GotoBottom()
return true
}
return false
}
+48
View File
@@ -0,0 +1,48 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestScrollFocusedPaneMenuUpDownMovesTree(t *testing.T) {
m := NewModel(nil)
m.focus = model.FocusMenu
m.syncMenuContent()
before := m.nav.Cursor()
if m.scrollFocusedPane("down") {
t.Fatal("down should not be consumed by menu viewport scroll")
}
m.moveDown()
m.syncFocusedPane()
if m.nav.Cursor() != before+1 {
t.Fatalf("cursor=%d want %d", m.nav.Cursor(), before+1)
}
}
func TestScrollFocusedPaneMenuPageKeysScrollViewport(t *testing.T) {
m := NewModel(nil)
m.focus = model.FocusMenu
for i := 0; i < 40; i++ {
m.nav.MoveDown()
}
m.syncMenuContent()
if !m.scrollFocusedPane("pgup") {
t.Fatal("pgup should scroll menu viewport")
}
}
func TestHandleMenuHorizontalRightOpensLeaf(t *testing.T) {
m := NewModel(nil)
m.focus = model.FocusMenu
// cursor starts on Projects leaf
next, cmd := m.handleMenuHorizontal(1)
if cmd == nil {
t.Fatal("expected load list command")
}
if next.listSpec.Subject != model.SubjectProjects {
t.Fatalf("expected projects list, got %+v", next.listSpec)
}
}

Some files were not shown because too many files have changed in this diff Show More