Compare commits

..
94 Commits
Author SHA1 Message Date
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
119 changed files with 12541 additions and 588 deletions
+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
+1 -1
View File
@@ -11,7 +11,7 @@ ONLYOFFICE_PASS=
# ONLYOFFICE_NAME=
# ONLYOFFICE_PASSWORD=
#
# produktor.io operator aliases (CLI-only):
# Optional CLI-only aliases:
# OO_URL=
# OO_USER=
# OO_PASS=
+14
View File
@@ -9,10 +9,12 @@ 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
@@ -23,6 +25,18 @@ jobs:
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 }}
+20 -1
View File
@@ -16,12 +16,31 @@ permissions:
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
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
- 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
+48 -2
View File
@@ -4,24 +4,70 @@ 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.22", "stable"]
go: ["1.25", "stable"]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go }}
cache: true
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.6.0"
".": "0.16.0"
}
+5 -3
View File
@@ -9,16 +9,17 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `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` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`). **`mails.go`** — OnlyOffice Workspace Mail addon (`ListMailAccounts`, `ListMailFolders`, `ListMailMessages`, `GetMailMessage`, `RemoveMailMessages`).
- `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`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
- `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.
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
- **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
@@ -27,6 +28,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- 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`.
+227
View File
@@ -4,6 +4,233 @@ 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)
+1 -1
View File
@@ -1,6 +1,6 @@
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
of this software and associated documentation files (the "Software"), to deal
+78 -60
View File
@@ -533,7 +533,7 @@ deals, total, _ := client.ListOpportunities(ctx, 50, 0)
company, _ := client.FindCompany(ctx, "ACME")
// Subtasks (form-encoded)
client.AddSubtask(ctx, "4242", "Prepare CV")
client.AddSubtask(ctx, "4242", "Prepare notes")
```
### oo (bundled CLI)
@@ -552,14 +552,13 @@ 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 CV"
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
oo applications sync --path ./applications/2026 --apply
```
### office (TUI)
@@ -608,12 +607,27 @@ go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
```bash
# Project Documents (files module)
oo projects files list 33
oo projects files upload 33 ./notes.md
oo projects files download 12345 --to ./copy.md
oo projects files rename 12345 notes-v2.md
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 ./cv.pdf
oo tasks files upload 208 ./notes.pdf
oo tasks files detach 208 12345
```
@@ -623,33 +637,28 @@ oo tasks files detach 208 12345
| `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` |
| `persons` | `list` (filtered), `create`, `delete` |
| `companies` | `list` (filtered), `create`, `delete` |
| `contacts` | `list`, `get`, `delete`, `info-add`, `dedupe-info` |
| `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`, `delete` |
| `mails` | `accounts`, `folders`, `list`, `get`, `draft`, `attach`, `draft-invoice`, `delete` |
| `cases` | `list`, `create`, `delete`, `member-add` |
| `crm-tasks` | `list`, `create`, `delete`, `categories` |
| `applications` | `sync` |
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. For produktor.io operator
files, `OO_URL` / `OO_USER` / `OO_PASS` are accepted as CLI-only aliases for
`ONLYOFFICE_URL` / `ONLYOFFICE_USER` / `ONLYOFFICE_PASS`.
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`,
> `oo applications-sync`, …) were replaced by subject-based equivalents
> (`oo calendar events`, `oo tasks list`, `oo contacts list`,
> `oo applications sync`). Flags on leaf commands are unchanged.
> 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
@@ -671,7 +680,7 @@ 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 ` @ contoso`, or the same
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:
@@ -710,13 +719,39 @@ oo contacts dedupe-info
# Two deals with the same title
oo opportunities dedupe
# Same contact attached twice to one deal (common after applications sync)
# Same contact attached twice to one deal
oo opportunities dedupe-members
# Titles like " @ contoso" or extra whitespace
# 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`):
@@ -728,41 +763,11 @@ oo crm cleanup --ignore-company-suffix
**Inspect before/after:**
```bash
oo opportunities list --count 200 | grep -i contoso
oo contacts get 857 -o json
oo opportunities list --count 200
oo contacts get CONTACT_ID -o json
oo crm cleanup -o json
```
### Job applications → CRM (`applications sync`)
**Problem:** You keep CVs in a folder tree (`applications/2026/Acme/README.md`)
and want companies, persons, deals, and history notes in OnlyOffice without
re-typing.
**Dry-run first** (default — prints what would happen, writes nothing):
```bash
oo applications sync --path ./applications/2026 --verbose
```
**Apply** when the preview looks right:
```bash
oo applications sync --path ./applications/2026 --apply --verbose
```
Each `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
The sync creates or finds contacts, opens a deal, adds members, and appends a
history note. Re-running is safe: duplicate members and duplicate deal titles
are skipped when already present.
**After a large sync**, run CRM cleanup to collapse duplicates introduced by
repeated runs or manual edits:
```bash
oo applications sync --path ./applications/2026 --apply
oo crm cleanup -o json
```
### Workspace mail (`oo mails`)
@@ -795,8 +800,21 @@ 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.
@@ -836,8 +854,8 @@ 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 CV to a hiring task
oo tasks files upload 208 ./cv.pdf
# Attach a file to a task
oo tasks files upload 208 ./notes.pdf
oo projects files list 33
```
@@ -845,7 +863,7 @@ oo projects files list 33
| When | Command |
|------|---------|
| After `applications sync --apply` | `oo crm cleanup` |
| 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` |
@@ -859,9 +877,9 @@ oo projects files list 33
| `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` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
| `OO_URL`, `OO_USER`, `OO_PASS` | Optional CLI-only aliases for `ONLYOFFICE_*` |
Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above.
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
+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()
}
+3 -1
View File
@@ -11,6 +11,7 @@ package onlyoffice
import (
"net/http"
"net/http/cookiejar"
"os"
"strings"
)
@@ -35,8 +36,9 @@ type Client struct {
// NewClient returns a new Client backed by http.DefaultClient.
func NewClient(c Credentials) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
client: http.DefaultClient,
client: &http.Client{Jar: jar},
credentials: &c,
}
}
+18
View File
@@ -139,6 +139,23 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
start := Time(time.Now().AddDate(0, 0, -2))
deadline := Time(time.Now().AddDate(0, 0, 2))
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,
Title: "integration parent task",
@@ -146,6 +163,7 @@ func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
StartDate: start,
Deadline: deadline,
Priority: int(TaskPriorityNormal),
MilestoneId: int(*ms.ID),
})
if err != nil {
t.Fatalf("CreateProjectTask: %v", err)
+2 -27
View File
@@ -101,6 +101,8 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
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
@@ -362,33 +364,6 @@ func mailFolderForSubject(subject model.Subject) (int, bool) {
}
}
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, len(users))
for i, u := range users {
id := ""
if u.ID != nil {
id = fmt.Sprint(*u.ID)
}
title := ""
if u.DisplayName != nil {
title = *u.DisplayName
}
email := ""
if u.Email != nil {
email = *u.Email
}
items[i] = model.Item{
ID: id, Title: title, Subtitle: email, Kind: model.KindUser,
Raw: map[string]any{"id": id, "displayName": title, "email": email},
}
}
return items, nil
}
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
resp, err := l.Client.GetProjectFiles(ctx, projectID)
if err != nil {
+12 -2
View File
@@ -16,9 +16,11 @@ func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.For
}
switch item.Kind {
case model.KindTask:
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
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)
}
@@ -60,5 +62,13 @@ func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFie
if err != nil {
return model.FormFields{}, err
}
return model.FormFieldsFromRaw(item.Kind, raw), nil
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
}
+32 -15
View File
@@ -9,8 +9,18 @@ import (
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// UpdateTask saves title and description for a project task.
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
// 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")
}
@@ -18,19 +28,26 @@ func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description stri
if err != nil {
return fmt.Errorf("task id %q: %w", taskID, err)
}
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
status := onlyoffice.ProjectTaskStatus(fields.TaskStatus)
if closing {
status = onlyoffice.ProjectTaskStatusClosed
}
req := onlyoffice.ProjectTaskUpdateRequest{
ID: id,
Title: title,
Description: description,
})
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
}
// TaskFields loads title and description for a project task item.
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
fields, err := l.DetailForm(ctx, item)
if err != nil {
return "", "", err
}
return fields.Primary, fields.Secondary, nil
}
+10 -10
View File
@@ -20,27 +20,27 @@ func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
t.Skip("no tasks")
}
item := items[0]
title, desc, err := loader.TaskFields(ctx, item)
fields, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatal(err)
}
restoreTitle, restoreDesc := title, desc
restore := fields
t.Cleanup(func() {
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
_ = loader.UpdateTask(context.Background(), item.ID, restore)
})
newTitle := title + " (office TUI test)"
newDesc := desc + "\n\n_edited by office integration test_"
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
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)
}
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
got, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatal(err)
}
if gotTitle != newTitle {
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
if got.Primary != fields.Primary {
t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
}
if gotDesc != newDesc {
if got.Secondary != fields.Secondary {
t.Fatalf("description mismatch")
}
}
+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
}
+7 -2
View File
@@ -6,6 +6,7 @@ type ActionID string
const (
ActionView ActionID = "view"
ActionSave ActionID = "save"
ActionClose ActionID = "close"
ActionDelete ActionID = "delete"
ActionRefresh ActionID = "refresh"
ActionDownload ActionID = "download"
@@ -29,7 +30,7 @@ func ActionsFor(kind Kind) []ItemAction {
case KindTask:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete task", Danger: true},
{ID: ActionClose, Label: "Close"},
}
case KindCRMTask:
return []ItemAction{
@@ -56,7 +57,11 @@ func ActionsFor(kind Kind) []ItemAction {
{ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true},
}
case KindEvent, KindCalendar, KindUser:
case KindUser:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
}
case KindEvent, KindCalendar:
return nil
default:
return nil
+45 -12
View File
@@ -29,7 +29,6 @@ var subjectExtraKeys = map[Subject][]string{
SubjectMailDrafts: {"to", "date"},
SubjectMailTrash: {"from", "date"},
SubjectMailSpam: {"from", "date"},
SubjectUsers: {"email", "displayName"},
}
// BuildColumns derives table columns from the list subject and item payloads.
@@ -37,6 +36,9 @@ 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)
}
@@ -102,18 +104,28 @@ func buildTaskColumns(items []Item) []TableColumn {
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 {
cols := []TableColumn{
_ = items
return []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "Status", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
{Key: "documents", Title: "Documents", Width: 11},
{Key: "users", Title: "Users", 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},
}
sizeColumns(cols, items)
return cols
}
// CellText returns the display string for one table cell.
@@ -141,7 +153,9 @@ func CellText(it Item, key string) string {
case "status":
switch it.Kind {
case KindProject:
return ProjectStatusLabel(it.Raw)
return ProjectStatusCell(it.Raw)
case KindUser:
return UserStatusLabel(it.Raw)
case KindTask, KindCRMTask:
if it.Raw == nil {
return ""
@@ -157,10 +171,15 @@ func CellText(it Item, key string) string {
if it.Raw == nil {
return ""
}
if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" {
// 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(it.Raw["deadline"])
return formatAny(rawDL)
case "responsible":
if it.Kind == KindTask || it.Kind == KindCRMTask {
return TaskResponsibleLabel(it.Raw)
@@ -171,6 +190,20 @@ func CellText(it Item, key string) string {
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":
+15 -2
View File
@@ -29,13 +29,26 @@ func TestBuildProjectColumns(t *testing.T) {
func TestCellTextProjectStatus(t *testing.T) {
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
if got := CellText(open, "status"); got != "Open" {
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" {
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) {
+92 -4
View File
@@ -11,7 +11,18 @@ type FormFields struct {
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.
@@ -62,10 +73,13 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
ReadOnly: true,
}
case KindUser:
acl := UserACLFromRaw(raw)
return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "Email",
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"),
ReadOnly: true,
HasUserEdit: true,
ReadOnly: false,
UserEnabled: UserIsEnabled(raw),
UserACL: acl,
GroupsText: UserGroupsText(raw),
}
case KindContact:
name := strRaw(raw, "displayName")
@@ -98,6 +112,20 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
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 == "" {
@@ -109,11 +137,71 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: title, Secondary: strRaw(raw, "description"),
ReadOnly: kind != KindTask && kind != KindProject,
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 ""
+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")
}
}
+1 -2
View File
@@ -66,8 +66,7 @@ func DefaultNavTree() *NavTree {
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", true, nil)
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
t.rebuildVisible()
return t
+21
View File
@@ -46,6 +46,27 @@ func ProjectStatusLabel(raw map[string]any) string {
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 {
+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")
}
}
+3
View File
@@ -169,6 +169,9 @@ func personName(v any) string {
return ""
}
if name := strRaw(m, "displayName"); name != "" {
if strings.EqualFold(name, "Profile has been removed") {
return ""
}
return name
}
first := strRaw(m, "firstName")
+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
}
+229 -83
View File
@@ -66,12 +66,11 @@ type Model struct {
loading bool
menuVP viewport.Model
listTable DataTable
listToolbar ListToolbar
detail DetailPane
showMenu bool
showList bool
showDetail bool
filterActive bool
filterSearch FilterSearch
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
@@ -86,14 +85,14 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
status: "Tab: pane · / filter · Space select row · 💾🗑 when selected · v: detail · q quit",
height: h,
width: w,
}
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable()
m.listToolbar = newListToolbar()
m.detail = newDetailPane()
m.filterSearch = newFilterSearch()
m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true
return m
@@ -117,20 +116,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
key := msg.String()
if key == "esc" && m.filterActive {
m.exitFilter()
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
m.listToolbar.FocusTable()
m.syncPaneFocus()
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Query() != "" {
m.listToolbar.ClearFilter()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
return m, nil
}
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneFilter {
switch key {
case "tab", "shift+tab", "backtab":
// allow pane switching while filter stays active
// allow pane switching
default:
cmd := m.filterSearch.Update(msg)
cmd := m.listToolbar.Update(msg, m.listToolbarMeta())
m.applyFilter()
return m, cmd
}
}
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneActions {
meta := m.listToolbarMeta()
switch key {
case "enter":
if act, ok := m.listToolbar.SelectedAction(meta); ok && m.listToolbar.IsActionEnabled(act, meta) {
return m, m.runListToolbarAction(act)
}
return m, nil
case "tab", "shift+tab", "backtab":
// allow pane switching
default:
cmd := m.listToolbar.Update(msg, meta)
return m, cmd
}
}
if key == "?" {
m.status = helpText()
return m, nil
@@ -138,9 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scrollFocusedPane(key) {
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview {
// filter input owns keys except pane switching handled above
} else if m.focus == model.FocusPreview {
if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd
}
@@ -155,39 +175,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionNextPane:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.detail.FocusFirstStop()
}
if m.focus == model.FocusList && prev != model.FocusList {
m.listToolbar.FocusTable()
}
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.detail.FocusFirstStop()
}
if m.focus == model.FocusList && prev != model.FocusList {
m.listToolbar.FocusTable()
}
m.syncPaneFocus()
return m, nil
case ActionMoveUp:
if m.focus == model.FocusPreview {
return m.handleDetailMove(-1)
}
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
return m, nil
}
m.moveUp()
m.syncFocusedPane()
if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveDown:
if m.focus == model.FocusPreview {
return m.handleDetailMove(1)
}
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
return m, nil
}
m.moveDown()
m.syncFocusedPane()
if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveTabStop(-1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
if m.focus == model.FocusMenu {
return m.handleMenuHorizontal(-1)
}
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.MoveCol(-1)
}
return m, nil
@@ -196,12 +237,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detail.MoveTabStop(1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
if m.focus == model.FocusMenu {
return m.handleMenuHorizontal(1)
}
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.MoveCol(1)
}
return m, nil
case ActionSort:
if m.focus == model.FocusList && m.hasList {
if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.ToggleSort()
}
return m, nil
@@ -236,7 +280,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.togglePane(3)
return m, nil
case ActionFilter:
m.enterFilter()
if m.hasList {
m.focus = model.FocusList
m.listToolbar.FocusFilter()
m.syncPaneFocus()
}
return m, nil
case ActionRefresh:
if m.hasList {
@@ -257,13 +305,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.syncMenuContent()
return m, nil
case "right", "l":
if spec, ok := m.nav.CurrentListSpec(); ok {
return m.withList(*spec)
}
m.nav.Activate()
m.syncMenuContent()
return m, nil
}
}
@@ -284,10 +325,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items)
if m.filterActive {
m.applyFilter()
}
}
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
case listMoreLoadedMsg:
@@ -337,8 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.updateItemAfterSave(msg.item, msg.fields)
m.detail.LoadForm(msg.item, msg.fields)
m.detail.form.MarkClean()
m.detail.SetFocused(m.focus == model.FocusPreview)
if msg.fields.HasTaskStatus && msg.fields.TaskStatus == model.TaskLifecycleClosed {
m.status = "Closed"
} else {
m.status = "Saved"
}
m.err = ""
}
return m, nil
@@ -350,6 +394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.status = msg.message
m.err = ""
m.selection.Clear()
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
@@ -376,11 +421,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
if m.listToolbar.Zone() == listZoneTable {
cmd = m.listTable.Update(msg)
case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg)
}
case model.FocusPreview:
cmd = m.detail.Update(msg)
}
return m, cmd
}
@@ -392,10 +437,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg)
}
}
return m, cmd
}
@@ -425,6 +468,9 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
if act.ID == model.ActionSave {
return m.saveDetailCmd(), true
}
if act.ID == model.ActionClose && m.detail.Item().Kind == model.KindTask {
return m.closeTaskCmd(), true
}
return m.executeActionCmd(act.ID, m.detail.Item()), true
}
}
@@ -435,7 +481,7 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
}
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if m.filterActive || !m.showDetail {
if !m.showDetail {
return nil, false
}
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
@@ -476,7 +522,7 @@ func (m Model) View() string {
h := m.paneHeight() + 2
var parts []string
if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(paneLipglossWidth(pw.Menu)).Height(h)
menuBody := ApplyVerticalScrollbar(
m.menuVP.View(),
m.menuVP.Width,
@@ -487,18 +533,17 @@ func (m Model) View() string {
parts = append(parts, menuStyle.Render(menuBody))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
parts = append(parts, listStyle.Render(m.listTable.View()))
listStyle := paneStyle(m.focus == model.FocusList).Width(paneLipglossWidth(pw.List)).Height(h)
meta := m.listToolbarMeta()
body := lipgloss.JoinVertical(lipgloss.Left,
m.listToolbar.View(meta),
m.listTable.View(),
)
parts = append(parts, listStyle.Render(body))
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
var detailBody string
if m.filterActive {
detailBody = m.filterSearch.View()
} else {
detailBody = m.detail.View()
}
parts = append(parts, prevStyle.Render(detailBody))
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(paneLipglossWidth(pw.Detail)).Height(h)
parts = append(parts, prevStyle.Render(m.detail.View()))
}
status := m.status
@@ -526,16 +571,105 @@ func (m Model) paneLayout() PaneWidths {
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
if m.filterActive {
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
m.detail.SetFocused(false)
return
onList := m.focus == model.FocusList && m.showList
m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable)
if onList {
m.listToolbar.syncInputFocus()
} else {
m.listToolbar.filter.SetFocused(false)
}
m.filterSearch.SetFocused(false)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
}
func (m Model) listToolbarMeta() ListToolbarMeta {
meta := ListToolbarMeta{
Subject: m.listTable.SubjectLabel(),
Count: m.listTable.ItemCount(),
SortLabel: m.listTable.SortHint(),
LoadingMore: m.listTable.LoadingMore(),
SaveEnabled: m.canSaveFromList(),
DeleteEnabled: m.canDeleteFromList(),
}
return meta
}
func (m Model) selectedItems() []model.Item {
var out []model.Item
for _, it := range m.items {
if it.Selected {
out = append(out, it)
}
}
return out
}
func (m Model) canSaveFromList() bool {
selected := m.selectedItems()
if len(selected) != 1 {
return false
}
for _, act := range model.ActionsFor(selected[0].Kind) {
if act.ID == model.ActionSave && m.detail.LoadedID() == selected[0].ID {
return true
}
}
return false
}
func (m Model) canDeleteFromList() bool {
selected := m.selectedItems()
if len(selected) == 0 {
return false
}
for _, it := range selected {
if !actionAvailable(model.ActionsFor(it.Kind), model.ActionDelete) {
return false
}
}
return true
}
func actionAvailable(actions []model.ItemAction, id model.ActionID) bool {
for _, a := range actions {
if a.ID == id {
return true
}
}
return false
}
func (m *Model) runListToolbarAction(action model.ActionID) tea.Cmd {
meta := m.listToolbarMeta()
if !m.listToolbar.IsActionEnabled(action, meta) {
return nil
}
switch action {
case model.ActionSave:
m.loading = true
return m.saveDetailCmd()
case model.ActionDelete:
m.loading = true
return m.deleteSelectedCmd()
default:
return nil
}
}
func (m *Model) deleteSelectedCmd() tea.Cmd {
selected := m.selectedItems()
if len(selected) == 0 {
return nil
}
if len(selected) == 1 {
return m.executeActionCmd(model.ActionDelete, selected[0])
}
cmds := make([]tea.Cmd, len(selected))
for i, it := range selected {
cmds[i] = m.executeActionCmd(model.ActionDelete, it)
}
return tea.Batch(cmds...)
}
func (m *Model) togglePane(which int) {
switch which {
case 1:
@@ -587,11 +721,12 @@ func (m *Model) layoutViewports() {
m.menuVP.Height = h
}
if pw.Visibility.List {
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
w := m.paneInnerWidth(pw.List)
m.listToolbar.SetWidth(w)
m.listTable.SetSize(w, h-listToolbarHeight)
}
if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
}
}
@@ -606,34 +741,14 @@ func (m *Model) syncListTable() {
return
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, q)
}
m.listTable.UpdateItems(items)
}
func (m *Model) enterFilter() {
if !m.showDetail {
m.showDetail = true
m.layoutViewports()
}
m.filterActive = true
m.focus = model.FocusPreview
m.syncPaneFocus()
m.applyFilter()
}
func (m *Model) exitFilter() {
m.filterActive = false
m.filterSearch.Clear()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
m.syncPaneFocus()
}
func (m *Model) applyFilter() {
q := m.filterSearch.Query()
q := m.listToolbar.Query()
m.nav.SetFilter(q)
m.syncMenuContent()
m.syncListTable()
@@ -645,8 +760,8 @@ func (m Model) listTableItem() (model.Item, bool) {
return model.Item{}, false
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, q)
}
if idx >= len(items) {
return model.Item{}, false
@@ -657,7 +772,7 @@ func (m Model) listTableItem() (model.Item, bool) {
func (m *Model) syncMenuContent() {
var b strings.Builder
title := "Navigation"
if m.filterActive && m.filterSearch.Query() != "" {
if m.listToolbar.Query() != "" {
title += " (filtered)"
}
b.WriteString(title + "\n\n")
@@ -705,6 +820,23 @@ func truncateRunes(s string, max int) string {
return string(r[:max-1]) + "…"
}
func (m Model) handleMenuHorizontal(delta int) (Model, tea.Cmd) {
cur := m.nav.Cursor()
switch delta {
case 1:
if spec, ok := m.nav.CurrentListSpec(); ok {
return m.withList(*spec)
}
m.nav.Activate()
case -1:
if m.nav.IsExpandable(cur) && m.nav.IsExpanded(cur) {
m.nav.ToggleExpand(cur)
}
}
m.syncMenuContent()
return m, nil
}
func (m *Model) syncFocusedPane() {
switch m.focus {
case model.FocusMenu:
@@ -790,9 +922,6 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
}
func (m *Model) onListRowChanged() tea.Cmd {
if m.filterActive {
return nil
}
if !m.hasList {
m.detail.Clear()
return m.maybeLoadMoreList()
@@ -814,7 +943,7 @@ func (m *Model) onListRowChanged() tea.Cmd {
const mailListLoadThreshold = 3
func (m *Model) maybeLoadMoreList() tea.Cmd {
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
if !m.hasList || m.listLoadingMore || !m.listHasMore {
return nil
}
if !model.SubjectIsMail(m.listSpec.Subject) {
@@ -850,6 +979,17 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
}
}
func (m *Model) closeTaskCmd() tea.Cmd {
item := m.detail.Item()
fields := m.detail.form.FormFields()
fields.TaskStatus = model.TaskLifecycleClosed
return func() tea.Msg {
ctx := context.Background()
err := m.loader.CloseTask(ctx, item.ID, fields)
return detailSavedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
fields := m.detail.form.FormFields()
@@ -874,6 +1014,12 @@ func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
if fields.HasTaskStatus {
m.items[i].Raw["status"] = int(fields.TaskStatus)
}
if fields.ResponsibleID != "" {
m.items[i].Raw["responsibleIds"] = []any{fields.ResponsibleID}
}
m.syncListTable()
return
}
@@ -899,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style {
}
func helpText() string {
return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
return "Alt+1/2/3: panes · / filter · Space select · 💾🗑 when selected · v: detail · Ctrl+S: save · q: quit"
}
+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)
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ func TestDetailPaneFormAndActions(t *testing.T) {
Primary: "Hello", Secondary: "World",
})
view := d.View()
if !strings.Contains(view, "Save") {
t.Fatal("expected Save action button")
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")
+266 -39
View File
@@ -16,6 +16,7 @@ const (
entityFieldPrimary entityField = iota
entityFieldSecondary
entityFieldStatus
entityFieldResponsible
)
// EntityForm is the top section of the detail pane for editable entities.
@@ -28,9 +29,22 @@ type EntityForm struct {
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
@@ -43,6 +57,7 @@ type entityFormStyles struct {
header lipgloss.Style
label lipgloss.Style
labelAct lipgloss.Style
meta lipgloss.Style
}
func newEntityFormStyles() entityFormStyles {
@@ -50,6 +65,7 @@ func newEntityFormStyles() 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")),
}
}
@@ -61,18 +77,30 @@ func newEntityForm() EntityForm {
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 {
n = 3
if f.hasStatus || f.hasTaskStatus {
n++
}
if f.hasResponsible {
n++
}
return n
}
@@ -86,7 +114,18 @@ func (f *EntityForm) FormFields() model.FormFields {
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(),
}
}
@@ -109,29 +148,66 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
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
if f.hasStatus && f.readOnly {
f.field = entityFieldStatus
}
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) {
@@ -141,6 +217,7 @@ func (f *EntityForm) SetFocused(on bool) {
} else {
f.primary.Blur()
f.secondary.Blur()
f.password.Blur()
}
}
@@ -165,7 +242,28 @@ func (f *EntityForm) layoutFields() {
inner = 8
}
f.primary.Width = inner
secH := f.height - 6
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
}
@@ -173,34 +271,49 @@ func (f *EntityForm) layoutFields() {
f.secondary.SetHeight(secH)
}
func (f *EntityForm) FocusNext() {
last := entityFieldSecondary
if f.hasStatus {
last = entityFieldStatus
func (f *EntityForm) lastField() entityField {
if f.hasResponsible {
return entityFieldResponsible
}
if f.field < last {
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() {
first := entityFieldPrimary
if f.field > first {
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 {
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
@@ -209,7 +322,19 @@ func (f *EntityForm) SetFieldIndex(i int) {
}
func (f *EntityForm) cycleStatus(delta int) {
if !f.hasStatus || f.readOnly {
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 {
@@ -220,7 +345,36 @@ func (f *EntityForm) cycleStatus(delta int) {
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()
@@ -243,7 +397,12 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus {
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)
@@ -255,6 +414,19 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
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 {
@@ -275,6 +447,9 @@ 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 {
@@ -284,43 +459,95 @@ func (f EntityForm) View() string {
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
statusBlock := ""
if f.hasStatus {
stLabel := f.styles.label.Render("Status")
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
valStyle := f.styles.label
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(" ←/→ toggle")
}
statusBlock = strings.Join([]string{
"",
stLabel,
valStyle.Render(f.status.Label()) + hint,
}, "\n")
}
statusBlock := f.renderStatusBlock()
responsibleBlock := f.renderResponsibleBlock()
metaBlock := f.renderMetaBlock()
ro := ""
if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
}
return strings.Join([]string{
parts := []string{
header + ro,
pLabel,
f.primary.View(),
"",
sLabel,
f.secondary.View(),
statusBlock,
}
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)
}
+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)
}
}
+12 -1
View File
@@ -25,7 +25,7 @@ type filterSearchStyles struct {
func newFilterSearch() FilterSearch {
in := textinput.New()
in.Prompt = "/ "
in.Placeholder = "Filter navigation and list…"
in.Placeholder = "Filter list…"
in.CharLimit = 256
return FilterSearch{
input: in,
@@ -76,6 +76,17 @@ func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
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())
+6 -1
View File
@@ -39,8 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action {
}
case "r":
return ActionRefresh
case "f":
case "f", "/":
if pane == model.FocusList {
return ActionFilter
}
if pane == model.FocusMenu {
return ActionFilter
}
case "up", "k":
return ActionMoveUp
case "down", "j":
+52
View File
@@ -1,9 +1,14 @@
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) {
@@ -66,6 +71,53 @@ func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
}
}
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 {
+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")
}
}
+30 -3
View File
@@ -8,7 +8,11 @@ import (
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":
@@ -37,17 +41,40 @@ func (m *Model) paneHeight() int {
return h
}
func (m *Model) paneInnerWidth(outer int) int {
if outer <= 0 {
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 := outer - 2 // border only; content fills inner box
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":
+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)
}
}
+2 -13
View File
@@ -4,7 +4,6 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
@@ -49,7 +48,7 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
out := make([]string, height)
for row := 0; row < height; row++ {
line := padDisplayWidth(lines[row], contentWidth)
line := padANSIWidth(lines[row], contentWidth)
ch := "│"
style := track
if row >= thumbStart && row < thumbEnd {
@@ -62,15 +61,5 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int)
}
func padDisplayWidth(line string, width int) string {
if width < 1 {
return ""
}
w := runewidth.StringWidth(line)
if w > width {
return runewidth.Truncate(line, width, "")
}
if w < width {
line += strings.Repeat(" ", width-w)
}
return line
return padANSIWidth(line, width)
}
+60 -172
View File
@@ -9,7 +9,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
type tableStyles struct {
@@ -80,7 +79,7 @@ func (t *DataTable) SetSize(w, h int) {
t.width = w
t.height = h
t.viewport.Width = w
t.viewport.Height = h - 2 // title + header
t.viewport.Height = h - 1 // column header
if t.viewport.Height < 1 {
t.viewport.Height = 1
}
@@ -260,155 +259,63 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
return nil
}
func (t DataTable) LoadingMore() bool { return t.loadingMore }
func (t DataTable) View() string {
if !t.ready {
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
}
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
if t.loadingMore {
title += t.styles.help.Render(" …")
}
sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲"
if !t.sortAsc {
dir = "▼"
}
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
return "\nSelect a leaf in the tree (•) and press Enter.\n"
}
header := t.renderHeader()
if t.hasVerticalScrollbar() {
header = padANSIWidth(header, t.lineContentWidth())
}
body := ApplyVerticalScrollbar(
t.viewport.View(),
t.viewport.Width,
t.width,
t.viewport.Height,
t.viewport.TotalLineCount(),
t.viewport.YOffset,
)
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content)
return lipgloss.JoinVertical(lipgloss.Left, header, body)
}
func (t DataTable) SortHint() string {
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
return ""
}
dir := "▲"
if !t.sortAsc {
dir = "▼"
}
return fmt.Sprintf("sort: %s %s", t.columns[t.sortCol].Title, dir)
}
func (t DataTable) SubjectLabel() string {
if !t.ready {
return "List"
}
return string(t.spec.Subject)
}
func (t DataTable) ItemCount() int {
return len(t.items)
}
func (t DataTable) hasVerticalScrollbar() bool {
return t.ready && t.viewport.TotalLineCount() > t.viewport.Height
}
func (t DataTable) lineContentWidth() int {
w := t.width
if t.hasVerticalScrollbar() && w > 1 {
w--
}
return w
}
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
widths = make(map[int]int)
if len(t.columns) == 0 || t.width <= 0 {
return nil, widths
}
indices = t.pickVisibleColumnIndices()
if len(indices) == 0 {
return indices, widths
}
minSum := 0
for _, i := range indices {
minSum += t.columns[i].Width
}
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
return indices, widths
}
func (t *DataTable) pickVisibleColumnIndices() []int {
var out []int
used := 0
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
w := t.columns[colIdx].Width
if len(out) > 0 && used+w > t.width {
break
}
out = append(out, colIdx)
used += w
}
if len(out) == 0 {
colIdx := t.colScroll
if colIdx < 0 || colIdx >= len(t.columns) {
colIdx = 0
}
out = []int{colIdx}
}
return out
}
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
out := make(map[int]int, len(indices))
if len(indices) == 0 {
return out
}
if total < len(indices) {
total = len(indices)
}
if minSum <= 0 {
each := total / len(indices)
if each < 1 {
each = 1
}
for _, i := range indices {
out[i] = each
}
fixColumnWidthSum(out, indices, total)
return out
}
for _, i := range indices {
out[i] = cols[i].Width
}
if minSum >= total {
for _, i := range indices {
out[i] = cols[i].Width * total / minSum
if out[i] < 1 {
out[i] = 1
}
}
fixColumnWidthSum(out, indices, total)
return out
}
extra := total - minSum
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
flex = append(flex, i)
}
}
if len(flex) == 0 {
flex = append(flex, indices...)
}
flexSum := 0
for _, i := range flex {
flexSum += cols[i].Width
}
if flexSum <= 0 {
flexSum = len(flex)
}
for _, i := range flex {
out[i] += extra * cols[i].Width / flexSum
}
fixColumnWidthSum(out, indices, total)
return out
}
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
if len(indices) == 0 {
return
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
widths[indices[len(indices)-1]] += total - sum
if widths[indices[len(indices)-1]] < 1 {
widths[indices[len(indices)-1]] = 1
}
}
const cellHPadding = 2 // lipgloss Padding(0, 1) on each side
func truncateCellText(text string, colWidth int) string {
inner := colWidth - cellHPadding
if inner < 1 {
inner = 1
}
if runewidth.StringWidth(text) <= inner {
return text
}
return runewidth.Truncate(text, inner, "...")
lay := t.computeLayout()
return lay.indices, lay.widths
}
func (t *DataTable) renderHeader() string {
@@ -430,7 +337,7 @@ func (t *DataTable) renderHeader() string {
if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort
}
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
cells = append(cells, renderTableCell(style, text, w))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
@@ -450,30 +357,24 @@ func (t *DataTable) refreshViewport() {
func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]]
selected := item.Selected
fullRow := row == t.cursorRow || selected
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
raw := strings.TrimSpace(model.CellText(item, col.Key))
text := raw
if !fullRow {
text = truncateCellText(raw, w)
}
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
text := truncateCellText(raw, w)
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
if full {
return s
}
return s.Width(width).MaxWidth(width)
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string {
if t.spec.Subject == model.SubjectProjects {
style := t.projectCellStyle(row, col, colKey, selected, item)
return renderTableCell(style, text, width)
}
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string {
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
@@ -495,29 +396,16 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m
default:
base = t.styles.cell
}
base = t.applyProjectRowStyle(base, item, colKey)
return applyCellWidth(base, width, fullWidth).Render(text)
}
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
if t.spec.Subject != model.SubjectProjects {
return base
}
open := model.ProjectIsOpen(item.Raw)
if colKey == "status" {
if open {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
if open {
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
}
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
return renderTableCell(base, text, width)
}
func (t *DataTable) ensureColVisible() {
vis := t.pickVisibleColumnIndices()
if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok && flex.FlexColumnKey != "" {
t.colScroll = 0
return
}
contentW := t.lineContentWidth()
vis := pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
if len(vis) == 0 {
return
}
@@ -528,7 +416,7 @@ func (t *DataTable) ensureColVisible() {
if t.cursorCol > last {
t.colScroll = t.cursorCol
for t.cursorCol >= 0 {
vis = t.pickVisibleColumnIndices()
vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
if len(vis) == 0 {
break
}
+225
View File
@@ -0,0 +1,225 @@
package ui
import (
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
type columnLayout struct {
indices []int
widths map[int]int
}
func (t *DataTable) computeLayout() columnLayout {
contentW := t.lineContentWidth()
if len(t.columns) == 0 || contentW <= 0 {
return columnLayout{widths: map[int]int{}}
}
if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok {
return layoutFlexTable(t.columns, contentW, flex.FlexColumnKey, flex.MinFlexWidth)
}
return layoutScrollingTable(t.columns, t.colScroll, contentW)
}
const minFixedColumnWidth = 3
// layoutFlexTable shows every column; flexKey absorbs leftover width.
func layoutFlexTable(cols []model.TableColumn, totalW int, flexKey string, minFlexWidth int) columnLayout {
indices := make([]int, len(cols))
for i := range cols {
indices[i] = i
}
widths := make(map[int]int, len(cols))
flexIdx := -1
fixed := 0
for i, col := range cols {
if col.Key == flexKey {
flexIdx = i
continue
}
widths[i] = col.Width
fixed += widths[i]
}
if flexIdx < 0 {
return layoutScrollingTable(cols, 0, totalW)
}
flexW := totalW - fixed
if flexW < minFlexWidth {
shrinkFixedColumns(widths, indices, flexIdx, fixed+minFlexWidth-totalW)
fixed = 0
for _, i := range indices {
if i != flexIdx {
fixed += widths[i]
}
}
flexW = totalW - fixed
if flexW < minFlexWidth {
flexW = minFlexWidth
}
}
widths[flexIdx] = flexW
normalizeWidthSum(widths, indices, totalW, flexIdx)
return columnLayout{indices: indices, widths: widths}
}
func shrinkFixedColumns(widths map[int]int, indices []int, flexIdx, need int) {
for need > 0 {
changed := false
for _, i := range indices {
if i == flexIdx || widths[i] <= minFixedColumnWidth {
continue
}
widths[i]--
need--
changed = true
if need == 0 {
return
}
}
if !changed {
return
}
}
}
func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout {
indices := pickVisibleColumnIndices(cols, colScroll, contentW)
widths := make(map[int]int, len(indices))
if len(indices) == 0 {
return columnLayout{indices: indices, widths: widths}
}
minSum := 0
for _, i := range indices {
minSum += cols[i].Width
}
widths = distributeColumnWidths(minSum, contentW, indices, cols)
return columnLayout{indices: indices, widths: widths}
}
func pickVisibleColumnIndices(cols []model.TableColumn, colScroll, contentW int) []int {
var out []int
used := 0
for colIdx := colScroll; colIdx < len(cols); colIdx++ {
w := cols[colIdx].Width
if len(out) > 0 && used+w > contentW {
break
}
out = append(out, colIdx)
used += w
}
if len(out) == 0 {
colIdx := colScroll
if colIdx < 0 || colIdx >= len(cols) {
colIdx = 0
}
out = []int{colIdx}
}
return out
}
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
out := make(map[int]int, len(indices))
if len(indices) == 0 {
return out
}
if total < len(indices) {
total = len(indices)
}
if minSum <= 0 {
each := total / len(indices)
if each < 1 {
each = 1
}
for _, i := range indices {
out[i] = each
}
normalizeWidthSum(out, indices, total, indices[len(indices)-1])
return out
}
for _, i := range indices {
out[i] = cols[i].Width
}
if minSum >= total {
for _, i := range indices {
out[i] = cols[i].Width * total / minSum
if out[i] < 1 {
out[i] = 1
}
}
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
return out
}
extra := total - minSum
flex := flexColumnIndices(indices, cols)
flexSum := 0
for _, i := range flex {
flexSum += cols[i].Width
}
if flexSum <= 0 {
flexSum = len(flex)
}
for _, i := range flex {
out[i] += extra * cols[i].Width / flexSum
}
normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols))
return out
}
func flexColumnIndices(indices []int, cols []model.TableColumn) []int {
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "type":
flex = append(flex, i)
}
}
if len(flex) == 0 {
flex = append(flex, indices...)
}
return flex
}
func flexColumnIndex(indices []int, cols []model.TableColumn) int {
flex := flexColumnIndices(indices, cols)
if len(flex) == 0 {
return indices[len(indices)-1]
}
for _, i := range indices {
if cols[i].Key == "title" {
return i
}
}
return flex[0]
}
func normalizeWidthSum(widths map[int]int, indices []int, total, adjustIdx int) {
if len(indices) == 0 {
return
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
if adjustIdx < 0 {
adjustIdx = indices[len(indices)-1]
}
widths[adjustIdx] += total - sum
if widths[adjustIdx] < 1 {
widths[adjustIdx] = 1
}
}
const cellHPadding = 0 // padding lives in outer lipgloss styles; column width is the full cell width
func truncateCellText(text string, colWidth int) string {
if colWidth < 1 {
colWidth = 1
}
if runewidth.StringWidth(text) <= colWidth {
return text
}
return runewidth.Truncate(text, colWidth, "…")
}
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestLayoutFlexTableAllProjectColumnsVisible(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
if !ok {
t.Fatal("expected flex layout for projects")
}
lay := layoutFlexTable(cols, 80, flex.FlexColumnKey, flex.MinFlexWidth)
if len(lay.indices) != 7 {
t.Fatalf("got %d visible columns, want 7", len(lay.indices))
}
sum := 0
for _, i := range lay.indices {
sum += lay.widths[i]
}
if sum != 80 {
t.Fatalf("width sum=%d want 80", sum)
}
}
func TestLayoutFlexTableUserEmailAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectUsers, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
if !ok {
t.Fatal("expected flex layout for users")
}
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 < 20 {
t.Fatalf("email width=%d should absorb remainder", emailW)
}
}
+48
View File
@@ -0,0 +1,48 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style {
if model.ProjectIsOpen(item.Raw) {
return lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("108"))
}
return lipgloss.NewStyle().
Background(lipgloss.Color("238")).
Foreground(lipgloss.Color("252"))
}
func (t *DataTable) projectCellStyle(row, col int, colKey string, selected bool, item model.Item) lipgloss.Style {
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
switch {
case selected && isCell:
return t.styles.cellSelect
case selected && isRow:
return t.styles.rowSelect
case selected:
return t.styles.rowSelect
case isCell:
return t.styles.cellActive
case t.focused && (isRow || isCol):
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
default:
return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item))
}
}
func (t *DataTable) projectStatusStyle(colKey string, item model.Item, base lipgloss.Style) lipgloss.Style {
if colKey != "status" {
return base
}
if model.ProjectIsOpen(item.Raw) {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
)
// renderTableCell renders one column using the bubbles/table pattern:
// truncate plain text, constrain with an inline inner box, then apply the outer style.
func renderTableCell(outer lipgloss.Style, text string, width int) string {
if width < 1 {
width = 1
}
frame := outer.GetHorizontalFrameSize()
innerW := width - frame
if innerW < 1 {
innerW = 1
}
truncated := runewidth.Truncate(text, innerW, "…")
inner := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Inline(true)
return outer.Render(inner.Render(truncated))
}
// padANSIWidth pads or truncates a styled line without breaking ANSI sequences.
func padANSIWidth(line string, width int) string {
if width < 1 {
return ""
}
w := ansi.StringWidth(line)
if w > width {
return ansi.Truncate(line, width, "")
}
if w < width {
return line + repeatSpace(width-w)
}
return line
}
func repeatSpace(n int) string {
if n <= 0 {
return ""
}
b := make([]byte, n)
for i := range b {
b[i] = ' '
}
return string(b)
}
+55
View File
@@ -0,0 +1,55 @@
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 TestProjectTableHeaderFitsPaneWidth(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
for _, w := range []int{68, 50, 40, 30} {
tbl := newDataTable()
tbl.SetSize(w, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
tbl.SetFocused(true)
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
header := tbl.renderHeader()
got := runewidth.StringWidth(re.ReplaceAllString(header, ""))
if got != w {
t.Fatalf("width=%d header display width=%d", w, got)
}
view := tbl.View()
line0 := strings.Split(strings.TrimSuffix(view, "\n"), "\n")[0]
gotView := runewidth.StringWidth(re.ReplaceAllString(line0, ""))
if gotView < w-1 || gotView > w+1 {
t.Fatalf("width=%d view header width=%d", w, gotView)
}
}
}
func TestPadANSIWidthPreservesStyledLine(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
line := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render("ID")
padded := padANSIWidth(line, 10)
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
if runewidth.StringWidth(re.ReplaceAllString(padded, "")) != 10 {
t.Fatalf("expected padded width 10, got %q", padded)
}
if !strings.Contains(padded, "ID") {
t.Fatalf("padding must not destroy cell text: %q", padded)
}
}
+147 -5
View File
@@ -1,11 +1,14 @@
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 sampleItems() []model.Item {
@@ -89,13 +92,125 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
}
}
func TestProjectTableTitleAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
if !ok {
t.Fatal("expected flex layout")
}
lay := layoutFlexTable(cols, 120, flex.FlexColumnKey, flex.MinFlexWidth)
if len(lay.indices) != len(cols) {
t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices))
}
sum := 0
titleW := 0
fixedSum := 0
titleIdx := indexOfColumnKey(cols, "title")
for _, i := range lay.indices {
sum += lay.widths[i]
if i == titleIdx {
titleW = lay.widths[i]
continue
}
fixedSum += lay.widths[i]
}
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
if titleW != 120-fixedSum {
t.Fatalf("title width=%d want remainder %d", titleW, 120-fixedSum)
}
if titleW < 40 {
t.Fatalf("title should absorb extra width, got %d", titleW)
}
}
func TestProjectTableFillsPaneWidth(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
indices, widths := tbl.visibleLayout()
if len(indices) != len(tbl.columns) {
t.Fatalf("expected all columns visible, got %d/%d", len(indices), len(tbl.columns))
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
if sum != 80 {
t.Fatalf("visible columns width sum=%d want 80", sum)
}
}
func TestProjectRowRenderWidth(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
tbl := newDataTable()
tbl.SetSize(120, 20)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "abc12345", Title: "My Project Title", Kind: model.KindProject, Raw: map[string]any{"status": 0}},
})
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
_, widths := tbl.visibleLayout()
want := 0
for _, w := range widths {
want += w
}
row := re.ReplaceAllString(tbl.renderRow(0), "")
got := runewidth.StringWidth(row)
if got < want-2 || got > want+2 {
t.Fatalf("row display width=%d want ~%d", got, want)
}
}
func TestProjectRowStaysSingleLineWhenSelected(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
long := strings.Repeat("X", 200)
tbl := newDataTable()
tbl.SetSize(120, 20)
tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{
{ID: "1", Title: long, Kind: model.KindProject, Raw: map[string]any{"status": 0}, Selected: true},
})
if strings.Contains(tbl.renderRow(0), "\n") {
t.Fatal("selected project row must not wrap to multiple lines")
}
}
func TestProjectRowStyleUniformBackground(t *testing.T) {
prev := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(prev) })
tbl := newDataTable()
item := model.Item{Raw: map[string]any{"status": 0}}
base := tbl.projectBaseStyle(item)
titleStyled := tbl.projectStatusStyle("title", item, base).Render("Open Proj")
statusStyled := tbl.projectStatusStyle("status", item, base).Render(model.ProjectStatusCell(item.Raw))
if !strings.Contains(titleStyled, "\x1b[") {
t.Fatalf("title cell should be styled: %q", titleStyled)
}
if !strings.Contains(statusStyled, "\x1b[") {
t.Fatalf("status cell should be styled: %q", statusStyled)
}
if titleStyled == statusStyled {
t.Fatal("status and title cells should use different foreground styles")
}
}
func TestTruncateCellText(t *testing.T) {
long := strings.Repeat("x", 40)
got := truncateCellText(long, 10)
if runewidth.StringWidth(got) > 10 {
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
}
if !strings.HasSuffix(got, "...") {
if !strings.HasSuffix(got, "…") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
}
@@ -116,8 +231,26 @@ func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
}
tbl.cursorRow = 1
if !strings.Contains(tbl.renderRow(1), long) {
t.Fatal("cursor row should show full title")
if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) {
t.Fatal("cursor row should show title text within column")
}
}
func TestDataTableRowWidthMatchesColumns(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.SetFocused(true)
_, widths := tbl.visibleLayout()
want := 0
for _, w := range widths {
want += w
}
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
row := re.ReplaceAllString(tbl.renderRow(0), "")
got := runewidth.StringWidth(row)
if got < want-2 || got > want+2 {
t.Fatalf("row display width=%d want ~%d", got, want)
}
}
@@ -133,8 +266,8 @@ func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
tbl.cursorRow = 0
rowSelected := tbl.renderRow(1)
if !strings.Contains(rowSelected, long) {
t.Fatal("space-selected row should show full title")
if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
t.Fatal("space-selected row should show title text within column")
}
}
@@ -146,3 +279,12 @@ func indexOfColumn(tbl DataTable, key string) int {
}
return 0
}
func indexOfColumnKey(cols []model.TableColumn, key string) int {
for i, c := range cols {
if c.Key == key {
return i
}
}
return 0
}
+298
View File
@@ -0,0 +1,298 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/eslider/go-onlyoffice/catalog"
"github.com/spf13/cobra"
)
var catalogCmd = &cobra.Command{
Use: "catalog",
Short: "Inventory clients/contacts from disk; match and apply to OO CRM",
Long: `Build a YAML catalog of persons/companies from contacts folders and project
trees, match against live OnlyOffice CRM, then create/update only rows with
approve: true.
Typical flow:
oo catalog scan-contacts --root PATH -O /tmp/contacts.yaml
oo catalog scan-projects --root ~/projects -O /tmp/projects.yaml
oo catalog scan-thunderbird --root PATH -O /tmp/thunderbird.yaml
oo catalog merge -i /tmp/contacts.yaml -i /tmp/projects.yaml -i /tmp/thunderbird.yaml -O docs/catalog/clients-contacts.yaml
oo catalog match -i docs/catalog/clients-contacts.yaml
# edit approve: true on pilot rows
oo catalog apply --dry-run -i docs/catalog/clients-contacts.yaml
oo catalog apply --apply -i docs/catalog/clients-contacts.yaml
`}
func init() {
rootCmd.AddCommand(catalogCmd)
catalogCmd.AddCommand(catalogScanContactsCmd())
catalogCmd.AddCommand(catalogScanProjectsCmd())
catalogCmd.AddCommand(catalogScanThunderbirdCmd())
catalogCmd.AddCommand(catalogMergeCmd())
catalogCmd.AddCommand(catalogMatchCmd())
catalogCmd.AddCommand(catalogApplyCmd())
}
func catalogScanContactsCmd() *cobra.Command {
var outPath string
cmd := &cobra.Command{
Use: "scan-contacts",
Short: "Parse VCF + folder stubs + email txt under a contacts root",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := cmd.Flags().GetString("root")
if root == "" {
return fmt.Errorf("--root is required")
}
doc, err := catalog.ScanContactsRoot(root)
if err != nil {
return err
}
return writeCatalog(cmd, doc, outPath)
},
}
cmd.Flags().String("root", "", "contacts directory (local path)")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path (default: stdout summary)")
_ = cmd.MarkFlagRequired("root")
return cmd
}
func catalogScanProjectsCmd() *cobra.Command {
var outPath string
var maxDepth int
cmd := &cobra.Command{
Use: "scan-projects",
Short: "Git roots / remotes / top-level dirs → company rows",
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := cmd.Flags().GetString("root")
if root == "" {
return fmt.Errorf("--root is required")
}
doc, err := catalog.ScanProjectsRoot(root, maxDepth)
if err != nil {
return err
}
return writeCatalog(cmd, doc, outPath)
},
}
cmd.Flags().String("root", "", "projects directory (local path)")
cmd.Flags().IntVar(&maxDepth, "max-depth", 4, "max directory depth for git roots")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
_ = cmd.MarkFlagRequired("root")
return cmd
}
func catalogScanThunderbirdCmd() *cobra.Command {
var outPath string
var mboxHeaders bool
cmd := &cobra.Command{
Use: "scan-thunderbird",
Short: "Thunderbird profiles: abook/history.mab + Gloda SQLite contacts",
Long: `Walk a directory tree for Thunderbird profiles and extract person rows from:
- *.mab address books (email regex)
- global-messages-db.sqlite Gloda contacts/identities
- optional --mbox-headers: From/To/Cc/Reply-To from mbox folder files (no bodies)
Noisy senders (noreply, Amazon marketplace, GitHub reply, …) are skipped.
Default zone is private; known work domains (e.g. wheregroup.com) get zone=warm role=work.`,
RunE: func(cmd *cobra.Command, args []string) error {
root, _ := cmd.Flags().GetString("root")
if root == "" {
return fmt.Errorf("--root is required")
}
doc, err := catalog.ScanThunderbirdRootOpts(root, catalog.ScanOptions{MboxHeaders: mboxHeaders})
if err != nil {
return err
}
return writeCatalog(cmd, doc, outPath)
},
}
cmd.Flags().String("root", "", "Thunderbird profile or parent directory (local path)")
cmd.Flags().BoolVar(&mboxHeaders, "mbox-headers", false, "also extract emails from mbox From/To/Cc headers")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write YAML to this path")
_ = cmd.MarkFlagRequired("root")
return cmd
}
func catalogMergeCmd() *cobra.Command {
var inputs []string
var outPath string
cmd := &cobra.Command{
Use: "merge",
Short: "Union + normalize catalog YAML files by entry id",
RunE: func(cmd *cobra.Command, args []string) error {
if len(inputs) == 0 {
return fmt.Errorf("at least one -i/--input required")
}
var docs []*catalog.Document
for _, p := range inputs {
d, err := catalog.LoadYAML(p)
if err != nil {
return err
}
docs = append(docs, d)
}
merged := catalog.MergeDocs(docs...)
if outPath == "" {
return fmt.Errorf("--out is required for merge")
}
if err := ensureParent(outPath); err != nil {
return err
}
if err := catalog.SaveYAML(outPath, merged); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "merged %d entries → %s\n", len(merged.Entries), outPath)
return nil
},
}
cmd.Flags().StringArrayVarP(&inputs, "input", "i", nil, "input catalog YAML (repeatable)")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output catalog YAML")
_ = cmd.MarkFlagRequired("out")
return cmd
}
func catalogMatchCmd() *cobra.Command {
var inPath, outPath string
cmd := &cobra.Command{
Use: "match",
Short: "Diff catalog vs live OO; set status new|exists|conflict and oo_id",
RunE: func(cmd *cobra.Command, args []string) error {
if inPath == "" {
return fmt.Errorf("--input is required")
}
doc, err := catalog.LoadYAML(inPath)
if err != nil {
return err
}
client, err := newOO(cmd)
if err != nil {
return err
}
if err := catalog.MatchAgainstOO(cmd.Context(), client, doc); err != nil {
return err
}
if outPath == "" {
outPath = inPath
}
if err := catalog.SaveYAML(outPath, doc); err != nil {
return err
}
counts := map[string]int{}
for _, e := range doc.Entries {
counts[e.Status]++
}
fmt.Fprintf(cmd.OutOrStdout(), "matched → %s new=%d exists=%d conflict=%d\n",
outPath, counts["new"], counts["exists"], counts["conflict"])
return nil
},
}
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "output path (default: overwrite input)")
_ = cmd.MarkFlagRequired("input")
return cmd
}
func catalogApplyCmd() *cobra.Command {
var inPath, outPath string
var dryRun, doApply bool
cmd := &cobra.Command{
Use: "apply",
Short: "Create/update OO contacts for approve:true rows",
RunE: func(cmd *cobra.Command, args []string) error {
if !dryRun && !doApply {
return fmt.Errorf("pass --dry-run or --apply")
}
if dryRun && doApply {
return fmt.Errorf("use either --dry-run or --apply, not both")
}
if inPath == "" {
return fmt.Errorf("--input is required")
}
doc, err := catalog.LoadYAML(inPath)
if err != nil {
return err
}
client, err := newOO(cmd)
if err != nil {
return err
}
res, err := catalog.ApplyApproved(cmd.Context(), client, doc, dryRun)
if err != nil {
return err
}
if !dryRun {
if outPath == "" {
outPath = inPath
}
if err := catalog.SaveYAML(outPath, doc); err != nil {
return err
}
}
if outputFormat == "json" {
printJSON(res)
return nil
}
mode := "apply"
if dryRun {
mode = "dry-run"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: created=%d updated=%d skipped=%d errors=%d\n",
mode, res.Created, res.Updated, res.Skipped, len(res.Errors))
for _, e := range res.Errors {
fmt.Fprintf(cmd.ErrOrStderr(), " error: %s\n", e)
}
return nil
},
}
cmd.Flags().StringVarP(&inPath, "input", "i", "", "catalog YAML")
cmd.Flags().StringVarP(&outPath, "out", "O", "", "write updated catalog after apply (default: overwrite input)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "count actions without writing to OO")
cmd.Flags().BoolVar(&doApply, "apply", false, "perform OO creates/updates")
_ = cmd.MarkFlagRequired("input")
return cmd
}
func writeCatalog(cmd *cobra.Command, doc *catalog.Document, outPath string) error {
if outPath != "" {
if err := ensureParent(outPath); err != nil {
return err
}
if err := catalog.SaveYAML(outPath, doc); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote %d entries → %s\n", len(doc.Entries), outPath)
return nil
}
if outputFormat == "json" {
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), string(b))
return nil
}
persons, companies := 0, 0
for _, e := range doc.Entries {
if e.Kind == "company" {
companies++
} else {
persons++
}
}
fmt.Fprintf(cmd.OutOrStdout(), "entries=%d persons=%d companies=%d (pass -O PATH to write YAML)\n",
len(doc.Entries), persons, companies)
return nil
}
func ensureParent(path string) error {
dir := filepath.Dir(path)
if dir == "" || dir == "." {
return nil
}
return os.MkdirAll(dir, 0o755)
}
+9 -2
View File
@@ -11,7 +11,7 @@ func TestRootRegistersSubjects(t *testing.T) {
want := []string{
"calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies",
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails",
"opportunities", "cases", "crm-tasks", "crm", "mails", "invoices",
}
got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() {
@@ -38,7 +38,7 @@ func TestRootHelpListsSubjects(t *testing.T) {
t.Fatal(err)
}
help := out.String()
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities", "invoices"} {
if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet)
}
@@ -53,6 +53,13 @@ func TestProjectsAlias(t *testing.T) {
if cmd.Name() != "projects" {
t.Fatalf("prj alias resolved to %q", cmd.Name())
}
ms, _, err := rootCmd.Find([]string{"projects", "milestone-create"})
if err != nil {
t.Fatal(err)
}
if ms.Name() != "milestone-create" {
t.Fatalf("milestone-create resolved to %q", ms.Name())
}
}
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
+1 -1
View File
@@ -20,7 +20,7 @@ var outputFormat = "table"
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI — subject-based command tree",
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).",
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, mails, invoices).",
Version: version,
SilenceUsage: true,
SilenceErrors: false,
+323 -2
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
@@ -39,12 +40,20 @@ func init() {
contactsCmd.AddCommand(contactsListCmd(nil))
contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsMergeCmd())
contactsCmd.AddCommand(contactsInfoAddCmd())
contactsCmd.AddCommand(contactsDedupeInfoCmd())
contactsCmd.AddCommand(contactsTagsCmd())
contactsCmd.AddCommand(contactsTagCreateCmd())
contactsCmd.AddCommand(contactsTagAddCmd())
contactsCmd.AddCommand(contactsTagRemoveCmd())
contactsCmd.AddCommand(contactsByTagCmd())
only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(personsUpdateCmd())
personsCmd.AddCommand(personsFixNamesCmd())
personsCmd.AddCommand(contactsDeleteCmd())
personsCmd.AddCommand(personsDedupeCmd())
@@ -146,6 +155,26 @@ func contactsDeleteCmd() *cobra.Command {
}
}
func contactsMergeCmd() *cobra.Command {
return &cobra.Command{
Use: "merge FROM_ID INTO_ID",
Short: "Merge FROM contact into INTO (FROM is removed)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.MergeContacts(cmd.Context(), args[1], args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func contactsInfoAddCmd() *cobra.Command {
var infoType, value, category string
var isPrimary bool
@@ -177,7 +206,7 @@ func contactsInfoAddCmd() *cobra.Command {
}
func personsCreateCmd() *cobra.Command {
var first, last, email, linkedin string
var first, last, email, linkedin, phone string
var companyID int
var jobTitle, about string
cmd := &cobra.Command{
@@ -200,6 +229,9 @@ func personsCreateCmd() *cobra.Command {
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if phone != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Phone", phone, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
@@ -213,12 +245,169 @@ func personsCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
cmd.Flags().StringVar(&about, "about", "", "about / bio")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
return cmd
}
func personsUpdateCmd() *cobra.Command {
var first, last string
var companyID int
var jobTitle, about string
cmd := &cobra.Command{
Use: "update PERSON_ID",
Short: "Update a person (name, company, job title, about)",
Long: `Updates CRM person fields via PUT /crm/contact/person/{id}.
--company-id 0 leaves the employer association unchanged.
Omit --first/--last to keep current names (fetched first).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid := args[0]
cur, err := c.GetContact(cmd.Context(), pid)
if err != nil {
return err
}
if first == "" {
if v, ok := cur["firstName"].(string); ok {
first = v
}
}
if last == "" {
if v, ok := cur["lastName"].(string); ok {
last = v
}
}
if first == "" || last == "" {
return fmt.Errorf("first and last name required (pass --first/--last or ensure contact has them)")
}
out, err := c.UpdatePerson(cmd.Context(), pid, first, last, companyID, jobTitle, about)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&first, "first", "", "first name (default: current)")
cmd.Flags().StringVar(&last, "last", "", "last name (default: current)")
cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id (0 = leave unchanged)")
cmd.Flags().StringVar(&jobTitle, "job-title", "", "job title")
cmd.Flags().StringVar(&about, "about", "", "about / bio")
return cmd
}
func personsFixNamesCmd() *cobra.Command {
var companyID int
var dryRun bool
var orgHint string
cmd := &cobra.Command{
Use: "fix-names",
Short: "Strip company annotations from last names; keep companyId link",
Long: `Repairs CRM persons whose lastName embeds a company ("Doe (Acme)")
or whose firstName is an email. Company belongs on companyId, not in the name.
With --company-id, only persons linked to that company are scanned.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
var list []map[string]any
if companyID > 0 {
list, err = c.ListCompanyPersons(ctx, strconv.Itoa(companyID))
} else {
all, err2 := c.ListAllContacts(ctx)
err = err2
for _, row := range all {
if isCo, _ := row["isCompany"].(bool); !isCo {
list = append(list, row)
}
}
}
if err != nil {
return err
}
fixed, skipped := 0, 0
for _, p := range list {
id := fmt.Sprint(p["id"])
// List payloads often omit contactInfos; refresh when name looks like email.
fn := strings.TrimSpace(fmt.Sprint(p["firstName"]))
if strings.Contains(fn, "@") || fn == "" {
if full, gerr := c.GetContact(ctx, id); gerr == nil && full != nil {
p = full
}
}
fn = strings.TrimSpace(fmt.Sprint(p["firstName"]))
ln := strings.TrimSpace(fmt.Sprint(p["lastName"]))
dn := strings.TrimSpace(fmt.Sprint(p["displayName"]))
org := orgHint
if org == "" {
if co, ok := p["company"].(map[string]any); ok {
org = strings.TrimSpace(fmt.Sprint(co["displayName"]))
}
}
emails := contactEmailsFromMap(p)
for _, row := range onlyoffice.ContactInfoRows(p) {
if onlyoffice.NormalizeContactInfoType(fmt.Sprint(row["infoType"])) == "email" {
if data := strings.TrimSpace(fmt.Sprint(row["data"])); data != "" {
emails = append(emails, data)
}
}
}
cf, cl := onlyoffice.CleanPersonNames(fn, ln, dn, org, emails)
needName := cf != fn || cl != ln
linkID := companyID
if linkID == 0 {
if co, ok := p["company"].(map[string]any); ok {
linkID = int(flexIDFloat(co["id"]))
}
}
if !needName {
skipped++
continue
}
row := map[string]any{
"id": id, "from": fn + " / " + ln, "to": cf + " / " + cl, "companyId": linkID,
}
if dryRun {
printObject(row)
fixed++
continue
}
if _, err := c.UpdatePerson(ctx, id, cf, cl, linkID, "", ""); err != nil {
return fmt.Errorf("update %s: %w", id, err)
}
printObject(row)
fixed++
}
printObject(map[string]any{"fixed": fixed, "skipped": skipped, "dry_run": dryRun})
return nil
},
}
cmd.Flags().IntVar(&companyID, "company-id", 0, "limit to persons of this company")
cmd.Flags().StringVar(&orgHint, "org", "", "org name hint for stripping suffixes")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned renames only")
return cmd
}
func contactEmailsFromMap(p map[string]any) []string {
var out []string
for _, k := range []string{"email", "primaryEmail"} {
if v := strings.TrimSpace(fmt.Sprint(p[k])); v != "" && v != "<nil>" {
out = append(out, v)
}
}
return out
}
func companiesCreateCmd() *cobra.Command {
var name, email, website string
var name, email, website, phone, about, street, city, state, zip, country string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
@@ -236,12 +425,34 @@ func companiesCreateCmd() *cobra.Command {
return err
}
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
if about != "" || street != "" {
aboutText := about
if street != "" {
addr := strings.TrimSpace(strings.Join([]string{street, zip, city, state, country}, ", "))
if aboutText != "" {
aboutText = aboutText + "\n" + addr
} else {
aboutText = "Billing address: " + addr
}
}
if updated, err := c.UpdateCompany(cmd.Context(), cid, name, aboutText); err == nil {
out = updated
}
}
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
}
if phone != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Phone", phone, "Work", true)
}
if street != "" {
if _, err := c.AddContactAddress(cmd.Context(), cid, street, city, state, zip, country, "Billing", true); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: address API failed (stored in about): %v\n", err)
}
}
printObject(out)
return nil
},
@@ -249,6 +460,13 @@ func companiesCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&name, "name", "", "company name")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
cmd.Flags().StringVar(&phone, "phone", "", "primary phone (adds ContactInfo)")
cmd.Flags().StringVar(&about, "about", "", "about / notes")
cmd.Flags().StringVar(&street, "street", "", "billing street")
cmd.Flags().StringVar(&city, "city", "", "billing city")
cmd.Flags().StringVar(&state, "state", "", "billing state")
cmd.Flags().StringVar(&zip, "zip", "", "billing zip")
cmd.Flags().StringVar(&country, "country", "", "billing country")
return cmd
}
@@ -311,3 +529,106 @@ func contactsDedupeInfoCmd() *cobra.Command {
}),
}
}
func contactsTagsCmd() *cobra.Command {
return &cobra.Command{
Use: "tags",
Short: "List CRM contact tags",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.ListContactTags(cmd.Context())
if err != nil {
return err
}
printTable([]string{"title", "relativeItemsCount"}, list)
return nil
},
}
}
func contactsTagCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "tag-create TAG",
Short: "Create a CRM contact tag",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.CreateContactTag(cmd.Context(), args[0]); err != nil {
return err
}
fmt.Println(args[0])
return nil
},
}
}
func contactsTagAddCmd() *cobra.Command {
return &cobra.Command{
Use: "tag-add CONTACT_ID TAG",
Short: "Attach a tag to a contact",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.AddContactTag(cmd.Context(), args[0], args[1]); err != nil {
return err
}
fmt.Printf("%s ← %s\n", args[0], args[1])
return nil
},
}
}
func contactsTagRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "tag-remove CONTACT_ID TAG",
Short: "Remove a tag from a contact",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if err := c.RemoveContactTag(cmd.Context(), args[0], args[1]); err != nil {
return err
}
fmt.Printf("%s ✕ %s\n", args[0], args[1])
return nil
},
}
}
func contactsByTagCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "by-tag TAG",
Short: "List contacts with a given CRM tag (ignore-list = tag ignore)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListContactsByTag(cmd.Context(), args[0], count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("tag=%s total: %d (shown: %d)\n", args[0], total, len(list))
}
printTable([]string{"id", "displayName", "isCompany", "title", "about"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
+103 -3
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
)
@@ -17,6 +18,7 @@ func init() {
rootCmd.AddCommand(crmTasksCmd)
crmTasksCmd.AddCommand(crmTasksListCmd())
crmTasksCmd.AddCommand(crmTasksCreateCmd())
crmTasksCmd.AddCommand(crmTasksReassignSelfCmd())
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
}
@@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command {
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
printTable([]string{"id", "title", "deadLine", "isClosed", "categoryID"}, list)
return nil
},
}
@@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a CRM task",
Short: "Create a CRM task (assigns self + deadline default now+14d)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
@@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command {
return nil
},
}
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601); default now+14d")
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
@@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command {
return cmd
}
func crmTaskNeedsOwner(t map[string]any) (need bool, name string) {
resp, _ := t["responsible"].(map[string]any)
rid, _ := resp["id"].(string)
name, _ = resp["displayName"].(string)
need = rid == "" || strings.EqualFold(name, "Profile has been removed")
return need, name
}
func crmTaskCategoryID(t map[string]any) int {
cat, ok := t["category"].(map[string]any)
if !ok {
return 0
}
switch v := cat["id"].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
func crmTasksReassignSelfCmd() *cobra.Command {
var apply bool
var count, max int
cmd := &cobra.Command{
Use: "reassign-self",
Short: "Reassign CRM tasks with missing/removed owner to the authenticated user",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
uid, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
type item struct {
id, title, deadline, ownerName string
categoryID int
}
var todos []item
scanned := 0
for start := 0; ; start += count {
list, total, err := c.ListCRMTasks(cmd.Context(), count, start)
if err != nil {
return err
}
for _, t := range list {
scanned++
need, name := crmTaskNeedsOwner(t)
if !need {
continue
}
title, _ := t["title"].(string)
dl, _ := t["deadLine"].(string)
todos = append(todos, item{
id: fmt.Sprint(t["id"]), title: title, deadline: dl,
ownerName: name, categoryID: crmTaskCategoryID(t),
})
if max > 0 && len(todos) >= max {
break
}
}
if (max > 0 && len(todos) >= max) || start+count >= total || len(list) == 0 {
break
}
}
fixed := 0
for _, t := range todos {
fmt.Printf(" task %s %q owner=%q → %s\n", t.id, t.title, t.ownerName, uid)
if !apply {
fixed++
continue
}
if _, err := c.UpdateCRMTask(cmd.Context(), t.id, t.title, t.deadline, t.categoryID, uid); err != nil {
return fmt.Errorf("update %s: %w", t.id, err)
}
fixed++
}
mode := "DRY-RUN"
if apply {
mode = "APPLIED"
}
fmt.Printf("%s: scanned=%d reassigned=%d (self=%s)\n", mode, scanned, fixed, uid)
return nil
},
}
cmd.Flags().BoolVar(&apply, "apply", false, "write changes (default dry-run)")
cmd.Flags().IntVar(&count, "count", 100, "page size")
cmd.Flags().IntVar(&max, "max", 0, "stop after N candidates (0 = all)")
return cmd
}
func crmTasksDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]",
+660
View File
@@ -0,0 +1,660 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/docpipe"
"github.com/eslider/go-onlyoffice/internal/xlspipe"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(docsCmd())
}
func docsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "docs",
Short: "Local document pipeline: md↔docx, OCR→PDF, extract Markdown",
Long: `Agent-friendly conversions (requires pandoc / ocrmypdf / pdftotext on PATH).
OnlyOffice Documents UI is poor for .md/.txt — keep sources in git, store .docx in OO.
Upload Markdown as DOCX: oo docs put-md PROJECT_ID file.md
Upload plain text: oo docs put-txt PROJECT_ID file.txt (preserves line breaks)
Upload/generate XLSX: oo docs put-xlsx PROJECT_ID [--template cutover-portugal | FILE.xlsx]
Read an OO file as MD: oo docs as-md FILE_ID
OCR a scan locally: oo docs ocr scan.pdf --md out.md
Structured OCR (hOCR→MD): oo docs hocr scan.jpg --md out.md --yaml out.yml`,
}
cmd.AddCommand(docsConvertCmd())
cmd.AddCommand(docsOptimizeCmd())
cmd.AddCommand(docsOCRCmd())
cmd.AddCommand(docsHOCRCmd())
cmd.AddCommand(docsAsMDCmd())
cmd.AddCommand(docsPutMDCmd())
cmd.AddCommand(docsPutTxtCmd())
cmd.AddCommand(docsPutXlsxCmd())
cmd.AddCommand(docsToolsCmd())
return cmd
}
func docsToolsCmd() *cobra.Command {
return &cobra.Command{
Use: "tools",
Short: "Show which converter binaries are on PATH",
RunE: func(cmd *cobra.Command, args []string) error {
t := docpipe.LookPath()
printObject(map[string]any{
"pandoc": strOrNil(t.Pandoc),
"ocrmypdf": strOrNil(t.OCRMyPDF),
"pdftotext": strOrNil(t.PDFToText),
"tesseract": strOrNil(t.Tesseract),
"ghostscript": strOrNil(t.Ghostscript),
})
return nil
},
}
}
func strOrNil(s string) any {
if s == "" {
return nil
}
return s
}
func docsConvertCmd() *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "convert PATH",
Short: "Convert a local file with pandoc (md↔docx by default)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
in := args[0]
t := docpipe.LookPath()
out := to
if out == "" {
switch docpipe.Ext(in) {
case ".md", ".markdown":
out = docpipe.SiblingDOCX(in)
case ".docx":
out = strings.TrimSuffix(in, docpipe.Ext(in)) + ".md"
default:
return fmt.Errorf("--to required for input type %s", docpipe.Ext(in))
}
}
if err := docpipe.EnsureDir(out); err != nil {
return err
}
if err := t.ConvertFile(in, out); err != nil {
return err
}
printObject(map[string]any{"in": in, "out": out})
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "output path (default: sibling .docx or .md)")
return cmd
}
func docsOptimizeCmd() *cobra.Command {
var out string
cmd := &cobra.Command{
Use: "optimize PDF_PATH",
Short: "Rewrite PDF via Ghostscript (PostScript pdfwrite) for OO-friendly size/text",
Long: `Use for InDesign/iText PDFs with a good text layer — avoids ocrmypdf invisible
text overlays that break OnlyOffice DocEditor. Skips OCR; rewrites via gs pdfwrite.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
in := args[0]
t := docpipe.LookPath()
if out == "" {
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
out = filepath.Join(filepath.Dir(in), base+".optimized.pdf")
}
if err := docpipe.EnsureDir(out); err != nil {
return err
}
chars, _ := t.PDFTextLayerChars(in)
if err := t.OptimizePDF(in, out); err != nil {
return err
}
outChars, _ := t.PDFTextLayerChars(out)
printObject(map[string]any{
"in": in,
"pdf": out,
"text_chars_in": chars,
"text_chars_out": outChars,
"note": "native text layer preserved; no OCR overlay",
})
return nil
},
}
cmd.Flags().StringVar(&out, "out", "", "output PDF (default: <name>.optimized.pdf)")
return cmd
}
func docsOCRCmd() *cobra.Command {
var out, mdOut, lang string
var force bool
var writeMD bool
cmd := &cobra.Command{
Use: "ocr PATH",
Short: "OCR image/PDF → searchable PDF (and optional Markdown)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
in := args[0]
t := docpipe.LookPath()
if out == "" {
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
out = filepath.Join(filepath.Dir(in), base+".ocr.pdf")
}
if err := docpipe.EnsureDir(out); err != nil {
return err
}
if err := t.OCRToPDF(in, out, force, lang); err != nil {
return err
}
res := map[string]any{"in": in, "pdf": out}
if writeMD || mdOut != "" {
if mdOut == "" {
mdOut = strings.TrimSuffix(out, filepath.Ext(out)) + ".md"
}
text, err := t.ExtractPDFText(out)
if err != nil {
return err
}
body := "# " + filepath.Base(in) + "\n\n" + strings.TrimSpace(text) + "\n"
if err := os.WriteFile(mdOut, []byte(body), 0o644); err != nil {
return err
}
res["md"] = mdOut
}
printObject(res)
return nil
},
}
cmd.Flags().StringVar(&out, "out", "", "output searchable PDF (default: <name>.ocr.pdf)")
cmd.Flags().StringVar(&mdOut, "md", "", "write Markdown extraction to this path")
cmd.Flags().BoolVar(&writeMD, "markdown", false, "also write sibling .md next to OCR PDF")
cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language(s) for tesseract/ocrmypdf")
cmd.Flags().BoolVar(&force, "force", false, "force OCR even if a text layer exists (default: skip when pdftotext finds enough text)")
return cmd
}
func docsHOCRCmd() *cobra.Command {
var mdOut, yamlOut, hocrOut, lang string
var dpi int
var minConf float32
cmd := &cobra.Command{
Use: "hocr PATH",
Short: "Tesseract hOCR → structured Markdown/YAML (via go-hocr)",
Long: `Runs tesseract with hOCR output, parses with go-hocr, writes Markdown
(and optional YAML). Better reading order / confidence than plain pdftotext.
For Spanish scans: --lang spa or spa+eng (needs tesseract-ocr-spa / TESSDATA_PREFIX).
Phone photos: --dpi 200..300.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
in := args[0]
t := docpipe.LookPath()
dir, err := os.MkdirTemp("", "oo-docs-hocr-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
res, err := t.ToHOCRMarkdown(in, dir, lang, dpi, minConf)
if err != nil {
return err
}
if mdOut == "" {
base := strings.TrimSuffix(filepath.Base(in), filepath.Ext(in))
mdOut = filepath.Join(filepath.Dir(in), base+".hocr.md")
}
if err := docpipe.EnsureDir(mdOut); err != nil {
return err
}
if err := os.WriteFile(mdOut, []byte(res.Markdown), 0o644); err != nil {
return err
}
obj := map[string]any{
"in": in,
"md": mdOut,
"did_ocr": res.DidOCR,
}
if hocrOut != "" {
if err := docpipe.EnsureDir(hocrOut); err != nil {
return err
}
b, err := os.ReadFile(res.HOCRPath)
if err != nil {
return err
}
if err := os.WriteFile(hocrOut, b, 0o644); err != nil {
return err
}
obj["hocr"] = hocrOut
} else {
obj["hocr_tmp"] = res.HOCRPath
}
if yamlOut != "" {
if err := docpipe.EnsureDir(yamlOut); err != nil {
return err
}
if err := os.WriteFile(yamlOut, []byte(res.YAML), 0o644); err != nil {
return err
}
obj["yaml"] = yamlOut
}
printObject(obj)
return nil
},
}
cmd.Flags().StringVar(&mdOut, "md", "", "Markdown output (default: <name>.hocr.md)")
cmd.Flags().StringVar(&yamlOut, "yaml", "", "also write structured YAML from go-hocr")
cmd.Flags().StringVar(&hocrOut, "hocr", "", "also keep raw .hocr file at this path")
cmd.Flags().StringVar(&lang, "lang", "eng", "tesseract language(s), e.g. spa+eng")
cmd.Flags().IntVar(&dpi, "dpi", 220, "hint DPI for phone photos / scans (0 = tesseract default)")
cmd.Flags().Float32Var(&minConf, "min-conf", 0, "drop words with OCR confidence below this (0 = keep all)")
return cmd
}
func docsAsMDCmd() *cobra.Command {
var to, lang string
var minChars int
var uploadOCR bool
var useHOCR bool
var dpi int
var minConf float32
cmd := &cobra.Command{
Use: "as-md FILE_ID",
Short: "Download an OO Documents file and emit Markdown (OCR PDF/image if needed)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ctx := cmd.Context()
meta, err := c.GetFile(ctx, args[0])
if err != nil {
return err
}
title := onlyoffice.FileEntryTitle(meta)
dir, err := os.MkdirTemp("", "oo-docs-as-md-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
local := filepath.Join(dir, onlyoffice.SafeLocalFileName(title))
f, err := os.Create(local)
if err != nil {
return err
}
if _, err := c.DownloadFile(ctx, args[0], f); err != nil {
_ = f.Close()
return err
}
_ = f.Close()
tools := docpipe.LookPath()
var res docpipe.Result
if useHOCR {
hr, err := tools.ToHOCRMarkdown(local, dir, lang, dpi, minConf)
if err != nil {
return err
}
res = docpipe.Result{Markdown: hr.Markdown, DidOCR: hr.DidOCR, Source: hr.Source}
} else {
res, err = tools.ToMarkdown(local, dir, lang, minChars)
if err != nil {
return err
}
}
outPath := to
if outPath == "" {
base := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title)))
if base == "" || base == "download" {
base = "file-" + args[0]
}
outPath = base + ".md"
}
if err := docpipe.EnsureDir(outPath); err != nil {
return err
}
if err := os.WriteFile(outPath, []byte(res.Markdown), 0o644); err != nil {
return err
}
obj := map[string]any{
"file_id": args[0],
"title": title,
"md": outPath,
"did_ocr": res.DidOCR,
}
if res.OCRPDFPath != "" && uploadOCR {
folderID := onlyoffice.FileFolderID(meta)
upName := strings.TrimSuffix(onlyoffice.SafeLocalFileName(title), filepath.Ext(onlyoffice.SafeLocalFileName(title))) + ".ocr.pdf"
tmpUp := filepath.Join(dir, upName)
data, err := os.ReadFile(res.OCRPDFPath)
if err != nil {
return err
}
if err := os.WriteFile(tmpUp, data, 0o644); err != nil {
return err
}
if folderID == "" {
obj["ocr_pdf_local"] = res.OCRPDFPath
obj["note"] = "file has no folderId; OCR PDF left local — pass after moving into a folder"
} else {
ent, err := c.UploadToFolder(ctx, folderID, tmpUp)
if err != nil {
return err
}
obj["ocr_pdf_file_id"] = fileIDStr(ent)
obj["ocr_pdf_title"] = onlyoffice.FileEntryTitle(ent)
}
} else if res.OCRPDFPath != "" {
// Keep OCR PDF outside temp by copying beside md if requested via env-less default:
kept := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + ".ocr.pdf"
if b, err := os.ReadFile(res.OCRPDFPath); err == nil {
_ = os.WriteFile(kept, b, 0o644)
obj["ocr_pdf_local"] = kept
} else {
obj["ocr_pdf_local"] = res.OCRPDFPath
}
}
printObject(obj)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "write Markdown to this path (default: ./<title>.md)")
cmd.Flags().StringVar(&lang, "lang", "eng", "OCR language")
cmd.Flags().IntVar(&minChars, "min-chars", docpipe.DefaultMinTextChars, "OCR PDF if text layer shorter than this")
cmd.Flags().BoolVar(&uploadOCR, "upload-ocr", false, "upload searchable OCR PDF back into the same OO folder")
cmd.Flags().BoolVar(&useHOCR, "hocr", false, "use tesseract hOCR + go-hocr instead of ocrmypdf/pdftotext")
cmd.Flags().IntVar(&dpi, "dpi", 220, "DPI hint when --hocr (phone photos)")
cmd.Flags().Float32Var(&minConf, "min-conf", 0, "drop low-confidence words when --hocr")
return cmd
}
func docsPutMDCmd() *cobra.Command {
var folderID string
var keepLocalDOCX string
var replace bool
cmd := &cobra.Command{
Use: "put-md PROJECT_ID MARKDOWN_PATH",
Short: "Convert Markdown→DOCX and upload DOCX into a project (OO-friendly)",
Long: `Agents edit .md locally; this uploads .docx so OnlyOffice can open/version it.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
pid, mdPath := args[0], args[1]
c, err := newOO(cmd)
if err != nil {
return err
}
tools := docpipe.LookPath()
dir, err := os.MkdirTemp("", "oo-docs-put-md-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
docxName := strings.TrimSuffix(filepath.Base(mdPath), filepath.Ext(mdPath)) + ".docx"
docxPath := filepath.Join(dir, docxName)
if err := tools.MDToDOCX(mdPath, docxPath); err != nil {
return err
}
if keepLocalDOCX != "" {
if err := docpipe.EnsureDir(keepLocalDOCX); err != nil {
return err
}
b, err := os.ReadFile(docxPath)
if err != nil {
return err
}
if err := os.WriteFile(keepLocalDOCX, b, 0o644); err != nil {
return err
}
}
ctx := cmd.Context()
var ent *onlyoffice.FileEntry
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
}
if err != nil {
return err
}
obj := map[string]any{
"project_id": pid,
"folder_id": folderID,
"md": mdPath,
"uploaded": fileEntryToMap(ent),
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil
}
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
if err != nil {
return err
}
printObject(map[string]any{
"project_id": pid,
"md": mdPath,
"uploaded": fileEntryToMap(ent),
})
return nil
},
}
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-md upsert)")
return cmd
}
func docsPutTxtCmd() *cobra.Command {
var folderID string
var keepLocalDOCX string
var replace bool
cmd := &cobra.Command{
Use: "put-txt PROJECT_ID TEXT_PATH",
Short: "Convert plain text→DOCX (preserve line breaks) and upload into a project",
Long: `OnlyOffice cannot render .txt well. This keeps each source line on its own DOCX line.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
pid, txtPath := args[0], args[1]
c, err := newOO(cmd)
if err != nil {
return err
}
tools := docpipe.LookPath()
dir, err := os.MkdirTemp("", "oo-docs-put-txt-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
docxName := strings.TrimSuffix(filepath.Base(txtPath), filepath.Ext(txtPath)) + ".docx"
docxPath := filepath.Join(dir, docxName)
if err := tools.TXTToDOCX(txtPath, docxPath); err != nil {
return err
}
if keepLocalDOCX != "" {
if err := docpipe.EnsureDir(keepLocalDOCX); err != nil {
return err
}
b, err := os.ReadFile(docxPath)
if err != nil {
return err
}
if err := os.WriteFile(keepLocalDOCX, b, 0o644); err != nil {
return err
}
}
ctx := cmd.Context()
var ent *onlyoffice.FileEntry
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
}
if err != nil {
return err
}
obj := map[string]any{
"project_id": pid,
"txt": txtPath,
"folder_id": folderID,
"uploaded": fileEntryToMap(ent),
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil
}
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
if err != nil {
return err
}
printObject(map[string]any{
"project_id": pid,
"txt": txtPath,
"uploaded": fileEntryToMap(ent),
})
return nil
},
}
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-txt upsert)")
return cmd
}
func docsPutXlsxCmd() *cobra.Command {
var folderID, template, title, keepLocal string
var replace bool
cmd := &cobra.Command{
Use: "put-xlsx PROJECT_ID [LOCAL_XLSX]",
Short: "Upload or generate an XLSX workbook into a project (excelize templates with formulas)",
Long: `Spreadsheets live in OnlyOffice — not in git. Generate multi-sheet workbooks with
formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an existing .xlsx.
oo docs put-xlsx 218 --template cutover-portugal
oo docs put-xlsx 218 --template cutover-portugal --title 2026-08-28-cutover-budget.xlsx
oo docs put-xlsx 218 ./my.xlsx`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
pid := args[0]
c, err := newOO(cmd)
if err != nil {
return err
}
dir, err := os.MkdirTemp("", "oo-docs-put-xlsx-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
var xlsxPath string
var srcLabel string
switch {
case template != "":
wb, err := xlspipe.BuildTemplate(template)
if err != nil {
return err
}
name := title
if name == "" {
name = "cutover-budget.xlsx"
if template == xlspipe.TemplateCutoverPortugal {
name = "2026-08-28-cutover-budget.xlsx"
}
}
if !strings.HasSuffix(strings.ToLower(name), ".xlsx") {
name += ".xlsx"
}
xlsxPath = filepath.Join(dir, name)
if err := xlspipe.Save(wb, xlsxPath); err != nil {
wb.Close()
return err
}
wb.Close()
srcLabel = "template:" + template
case len(args) == 2:
xlsxPath = args[1]
if docpipe.Ext(xlsxPath) != ".xlsx" {
return fmt.Errorf("expected .xlsx, got %s", docpipe.Ext(xlsxPath))
}
srcLabel = xlsxPath
default:
return fmt.Errorf("pass LOCAL_XLSX or --template")
}
if keepLocal != "" {
b, err := os.ReadFile(xlsxPath)
if err != nil {
return err
}
if err := docpipe.EnsureDir(keepLocal); err != nil {
return err
}
if err := os.WriteFile(keepLocal, b, 0o644); err != nil {
return err
}
}
ctx := cmd.Context()
var ent *onlyoffice.FileEntry
var deleted []int
if folderID != "" {
if replace {
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, xlsxPath)
} else {
ent, err = c.UploadToFolder(ctx, folderID, xlsxPath)
}
} else {
if replace {
ent, deleted, err = c.UploadProjectFileReplacing(ctx, pid, xlsxPath)
} else {
ent, err = c.UploadProjectFile(ctx, pid, xlsxPath)
}
}
if err != nil {
return err
}
obj := map[string]any{
"project_id": pid,
"source": srcLabel,
"uploaded": fileEntryToMap(ent),
}
if folderID != "" {
obj["folder_id"] = folderID
}
if len(deleted) > 0 {
obj["replaced_file_ids"] = deleted
}
printObject(obj)
return nil
},
}
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files before upload")
return cmd
}
+441
View File
@@ -0,0 +1,441 @@
package main
import (
"fmt"
"strings"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var invoicesCmd = &cobra.Command{
Use: "invoices",
Aliases: []string{"invoice"},
Short: "CRM invoices (billing)",
}
func init() {
rootCmd.AddCommand(invoicesCmd)
invoicesCmd.AddCommand(invoiceListCmd())
invoicesCmd.AddCommand(invoiceGetCmd())
invoicesCmd.AddCommand(invoiceCreateCmd())
invoicesCmd.AddCommand(invoiceUpdateCmd())
invoicesCmd.AddCommand(invoicePDFCmd())
invoicesCmd.AddCommand(invoicePDFCleanupCmd())
invoicesCmd.AddCommand(invoiceStatusCmd())
invoicesCmd.AddCommand(invoiceDeleteCmd())
invoicesCmd.AddCommand(invoiceItemsCmd())
}
func invoiceListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM invoices",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListInvoices(cmd.Context(), count, offset)
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(list))
for _, inv := range list {
rows = append(rows, onlyoffice.FlattenInvoiceRow(inv))
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(rows))
}
printTable([]string{"id", "number", "cost", "statusTitle", "contactName", "issueDate", "dueDate"}, rows)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func invoiceGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get INVOICE_ID",
Short: "Show an invoice by id (incl. lines)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetInvoice(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func invoiceCreateCmd() *cobra.Command {
var (
number, issueDate, dueDate, language, currency, terms, description, po string
contactID, consigneeID, itemID, opportunityID int64
price, qty float64
lineDesc string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a draft invoice with one line",
Long: `Create a CRM invoice (Draft) with a single line.
Always pass --opportunity when a deal exists (entity link at create). Updating
--opportunity later often fails with HTTP 400 — see docs/crm-associations.md.
Example:
oo invoices create --number INV-2026-01 --contact CONTACT_ID --item ITEM_ID \
--price 300 --opportunity OPPORTUNITY_ID --line-description "Service package" \
--terms "…payment terms…"
`,
RunE: func(cmd *cobra.Command, args []string) error {
if number == "" || contactID == 0 || itemID == 0 {
return fmt.Errorf("--number, --contact and --item are required")
}
if issueDate == "" {
issueDate = time.Now().Format("2006-01-02") + "T00:00:00.0000000+01:00"
} else if !strings.Contains(issueDate, "T") {
issueDate = issueDate + "T00:00:00.0000000+01:00"
}
if dueDate == "" {
dueDate = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02") + "T00:00:00.0000000+01:00"
} else if !strings.Contains(dueDate, "T") {
dueDate = dueDate + "T00:00:00.0000000+01:00"
}
if qty == 0 {
qty = 1
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateInvoice(cmd.Context(), onlyoffice.CreateInvoiceParams{
Number: number,
IssueDate: issueDate,
DueDate: dueDate,
ContactID: contactID,
ConsigneeID: consigneeID,
EntityID: opportunityID,
EntityType: 0, // Opportunity
Language: language,
Currency: currency,
ExchangeRate: 1,
PurchaseOrderNumber: po,
Terms: terms,
Description: description,
Lines: []onlyoffice.InvoiceLine{{
InvoiceItemID: itemID,
Description: lineDesc,
Quantity: qty,
Price: price,
SortOrder: 0,
}},
})
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&number, "number", "", "invoice number (e.g. INV-2026-01)")
cmd.Flags().Int64Var(&contactID, "contact", 0, "bill-to company contact id (canonical company, not a PDF-only clone)")
cmd.Flags().Int64Var(&consigneeID, "consignee", 0, "optional Empfänger person/contact id")
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "link to CRM opportunity/deal id (set at create)")
cmd.Flags().Int64Var(&itemID, "item", 0, "catalog invoice item id")
cmd.Flags().Float64Var(&price, "price", 0, "line price")
cmd.Flags().Float64Var(&qty, "qty", 1, "line quantity")
cmd.Flags().StringVar(&lineDesc, "line-description", "", "line description override")
cmd.Flags().StringVar(&issueDate, "issue-date", "", "YYYY-MM-DD (default today)")
cmd.Flags().StringVar(&dueDate, "due-date", "", "YYYY-MM-DD (default +14d)")
cmd.Flags().StringVar(&language, "language", "de-DE", "invoice language")
cmd.Flags().StringVar(&currency, "currency", "EUR", "currency abbreviation")
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer")
cmd.Flags().StringVar(&description, "description", "", "invoice description")
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
return cmd
}
func invoiceUpdateCmd() *cobra.Command {
var opportunityID int64
var description, po, terms string
cmd := &cobra.Command{
Use: "update INVOICE_ID",
Short: "Update invoice (notes, PO, terms; opportunity link unreliable)",
Long: `Update Draft invoice fields.
--opportunity often returns HTTP 400 on existing invoices. Prefer
oo invoices create … --opportunity, or delete+recreate. See docs/crm-associations.md.
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if opportunityID == 0 && !cmd.Flags().Changed("description") && !cmd.Flags().Changed("po") && !cmd.Flags().Changed("terms") {
return fmt.Errorf("set --opportunity and/or --description and/or --po and/or --terms")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.UpdateInvoice(cmd.Context(), args[0], onlyoffice.UpdateInvoiceParams{
EntityID: opportunityID,
EntityType: 0,
Description: description,
DescriptionSet: cmd.Flags().Changed("description"),
PurchaseOrder: po,
PurchaseOrderSet: cmd.Flags().Changed("po"),
Terms: terms,
TermsSet: cmd.Flags().Changed("terms"),
})
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().Int64Var(&opportunityID, "opportunity", 0, "CRM opportunity/deal id (may 400 — prefer create)")
cmd.Flags().StringVar(&description, "description", "", "invoice notes (Notizen); use \\n for line breaks")
cmd.Flags().StringVar(&po, "po", "", "purchase order number")
cmd.Flags().StringVar(&terms, "terms", "", "payment terms / footer (Bedingungen)")
return cmd
}
func invoicePDFCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "pdf INVOICE_ID",
Short: "Return / regenerate invoice PDF file metadata",
Long: `GET /api/2.0/crm/invoice/{id}/pdf.
With --force, touches the Draft to clear cached fileID first (layout changes).
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
var out map[string]any
if force {
out, err = c.ForceRegenerateInvoicePDF(cmd.Context(), args[0])
} else {
out, err = c.InvoicePDFFile(cmd.Context(), args[0])
}
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().BoolVar(&force, "force", false, "clear PDF cache then regenerate")
return cmd
}
func invoicePDFCleanupCmd() *cobra.Command {
return &cobra.Command{
Use: "pdf-cleanup INVOICE_ID",
Short: "Delete older P-*.pdf copies on company/deal; keep invoice.fileID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
deleted, err := c.PurgeStaleInvoicePDFs(cmd.Context(), args[0])
if err != nil {
return err
}
if outputFormat == "json" {
printObject(map[string]any{"deleted": deleted})
return nil
}
fmt.Printf("deleted %d stale PDF file(s): %v\n", len(deleted), deleted)
return nil
},
}
}
func invoiceStatusCmd() *cobra.Command {
var status string
cmd := &cobra.Command{
Use: "status INVOICE_ID [INVOICE_ID...]",
Short: "Set invoice status (draft|billed|rejected|paid)",
Long: `PUT /api/2.0/crm/invoice/status/{id}.
Billed invoices are not content-editable. Billed→Draft often does not work —
recreate as Draft instead (docs/crm-associations.md).
`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
statusID, err := parseInvoiceStatus(status)
if err != nil {
return err
}
ids := make([]int64, 0, len(args))
for _, a := range args {
var id int64
if _, err := fmt.Sscan(a, &id); err != nil || id <= 0 {
return fmt.Errorf("invalid invoice id %q", a)
}
ids = append(ids, id)
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.SetInvoiceStatus(cmd.Context(), statusID, ids...)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&status, "status", "draft", "draft|billed|rejected|paid or numeric id")
return cmd
}
func parseInvoiceStatus(s string) (int, error) {
s = strings.TrimSpace(strings.ToLower(s))
switch s {
case "", "draft", "1":
return onlyoffice.InvoiceStatusDraft, nil
case "billed", "2":
return onlyoffice.InvoiceStatusBilled, nil
case "rejected", "3":
return onlyoffice.InvoiceStatusRejected, nil
case "paid", "4":
return onlyoffice.InvoiceStatusPaid, nil
default:
var n int
if _, err := fmt.Sscan(s, &n); err != nil || n <= 0 {
return 0, fmt.Errorf("unknown status %q (draft|billed|rejected|paid)", s)
}
return n, nil
}
}
func invoiceDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete INVOICE_ID [INVOICE_ID...]",
Short: "Delete one or more invoices",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
if _, err := c.DeleteInvoice(cmd.Context(), id); err != nil {
return err
}
fmt.Println("deleted", id)
}
return nil
},
}
}
func invoiceItemsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "items",
Aliases: []string{"item"},
Short: "Invoice catalog items",
}
cmd.AddCommand(invoiceItemsListCmd())
cmd.AddCommand(invoiceItemsCreateCmd())
cmd.AddCommand(invoiceItemsDeleteCmd())
return cmd
}
func invoiceItemsListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List catalog invoice items",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListInvoiceItems(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
for _, row := range list {
if cur, ok := row["currency"].(map[string]any); ok {
row["currency"] = cur["abbreviation"]
}
}
}
printTable([]string{"id", "title", "price", "currency", "description"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func invoiceItemsCreateCmd() *cobra.Command {
var title, description, currency string
var price float64
cmd := &cobra.Command{
Use: "create",
Short: "Create a catalog invoice item",
RunE: func(cmd *cobra.Command, args []string) error {
if title == "" {
return fmt.Errorf("--title is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateInvoiceItem(cmd.Context(), title, description, price, currency)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "item title")
cmd.Flags().StringVar(&description, "description", "", "item description")
cmd.Flags().Float64Var(&price, "price", 0, "unit price")
cmd.Flags().StringVar(&currency, "currency", "EUR", "currency")
return cmd
}
func invoiceItemsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete ITEM_ID [ITEM_ID...]",
Short: "Delete catalog invoice items",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
if _, err := c.DeleteInvoiceItem(cmd.Context(), id); err != nil {
return err
}
fmt.Println("deleted", id)
}
return nil
},
}
}
+288 -1
View File
@@ -1,6 +1,8 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
@@ -11,7 +13,7 @@ import (
var mailsCmd = &cobra.Command{
Use: "mails",
Aliases: []string{"mail"},
Short: "OnlyOffice Workspace mail — list, read, delete",
Short: "OnlyOffice Workspace mail — list, read, draft, delete",
}
func init() {
@@ -20,6 +22,11 @@ func init() {
mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd())
mailsCmd.AddCommand(mailsDownloadAttachmentCmd())
mailsCmd.AddCommand(mailsDraftCmd())
mailsCmd.AddCommand(mailsAttachCmd())
mailsCmd.AddCommand(mailsDraftInvoiceCmd())
mailsCmd.AddCommand(mailsSendCmd())
mailsCmd.AddCommand(mailsDeleteCmd())
}
@@ -126,6 +133,286 @@ func mailsGetCmd() *cobra.Command {
}
}
func mailsDownloadAttachmentCmd() *cobra.Command {
var outPath string
cmd := &cobra.Command{
Use: "download-attachment ATTACHMENT_ID",
Short: "Download a mail attachment by attachment id",
Long: `Download a raw attachment from OnlyOffice Mail's download.ashx handler.
Example:
oo mails download-attachment 12345 --out /tmp/attach.bin
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(outPath) == "" {
return fmt.Errorf("--out is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
body, err := c.DownloadMailAttachment(cmd.Context(), args[0])
if err != nil {
return err
}
if err := writeMailAttachment(outPath, body); err != nil {
return err
}
if outputFormat == "json" {
printObject(map[string]any{
"attachmentId": args[0],
"bytes": len(body),
"path": outPath,
})
return nil
}
fmt.Printf("saved %d bytes to %s\n", len(body), outPath)
return nil
},
}
cmd.Flags().StringVar(&outPath, "out", "", "output file path")
return cmd
}
func mailsDraftCmd() *cobra.Command {
var from, to, cc, bcc, subject, body, html string
var id int64
cmd := &cobra.Command{
Use: "draft",
Short: "Create or update a mail draft (OnlyOffice Mail)",
Long: `Save a draft in /addons/mail (PUT /api/2.0/mail/drafts/save).
oo mails draft --to a@b.com --subject "…" --body "<p>…</p>"
oo mails draft --id 123 --to a@b.com --subject "…" --html "<p>…</p>"
`,
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" {
return fmt.Errorf("--to is required")
}
htmlBody := body
if html != "" {
htmlBody = html
}
if htmlBody == "" {
return fmt.Errorf("--body or --html is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
ID: id,
From: from,
To: to,
Cc: cc,
Bcc: bcc,
Subject: subject,
Body: onlyoffice.PlainTextToMailHTML(htmlBody),
})
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id (0 = create)")
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
cmd.Flags().StringVar(&to, "to", "", "recipient (required)")
cmd.Flags().StringVar(&cc, "cc", "", "cc")
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
cmd.Flags().StringVar(&subject, "subject", "", "subject")
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
return cmd
}
func mailsAttachCmd() *cobra.Command {
var fileID int64
cmd := &cobra.Command{
Use: "attach MESSAGE_ID",
Short: "Attach an OnlyOffice Files document to a draft/message",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if fileID <= 0 {
return fmt.Errorf("--file-id is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AttachMailDocument(cmd.Context(), args[0], fileID)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().Int64Var(&fileID, "file-id", 0, "OnlyOffice file id (e.g. invoice PDF)")
return cmd
}
func mailsDraftInvoiceCmd() *cobra.Command {
var to, from, subject, body string
var invoiceID int
cmd := &cobra.Command{
Use: "draft-invoice",
Short: "Create a mail draft with regenerated invoice PDF attached",
Long: `Regenerate the CRM invoice PDF, save an OnlyOffice Mail draft, and attach the PDF.
Does not send. Open /addons/mail/#drafts to review.
oo mails draft-invoice --invoice 16 --to info@example.com
`,
RunE: func(cmd *cobra.Command, args []string) error {
if invoiceID <= 0 || to == "" {
return fmt.Errorf("--invoice and --to are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
pdf, err := c.ForceRegenerateInvoicePDF(cmd.Context(), strconv.Itoa(invoiceID))
if err != nil {
return err
}
fileID := onlyoffice.Int64FromMap(pdf, "id")
if fileID <= 0 {
return fmt.Errorf("invoice PDF has no file id: %+v", pdf)
}
inv, err := c.GetInvoice(cmd.Context(), strconv.Itoa(invoiceID))
if err != nil {
return err
}
number := strings.TrimSpace(fmt.Sprint(inv["number"]))
cost := formatInvoiceCostEUR(inv["cost"])
if subject == "" {
subject = fmt.Sprintf("Invoice %s (%s EUR)", number, cost)
}
if body == "" {
body = fmt.Sprintf(`Hello,
please find attached invoice %s for %s EUR.
Payment terms: see invoice notes.
Best regards`, number, cost)
}
draft, err := c.SaveMailDraft(cmd.Context(), onlyoffice.SaveMailDraftParams{
From: from,
To: to,
Subject: subject,
Body: onlyoffice.MailHTMLWithBlankParagraphs(onlyoffice.PlainTextToMailHTML(body)),
})
if err != nil {
return err
}
mid := fmt.Sprint(draft["id"])
att, err := c.AttachMailDocument(cmd.Context(), mid, fileID)
if err != nil {
return fmt.Errorf("draft %s created but attach failed: %w", mid, err)
}
if outputFormat == "json" {
printObject(map[string]any{"draft": draft, "attachment": att, "pdfFileId": fileID})
return nil
}
fmt.Printf("draft %s to=%s subject=%q pdfFileId=%d\n", mid, to, subject, fileID)
fmt.Printf("open: %s/addons/mail/#drafts\n", strings.TrimRight(os.Getenv("ONLYOFFICE_URL"), "/"))
return nil
},
}
cmd.Flags().IntVar(&invoiceID, "invoice", 0, "CRM invoice id")
cmd.Flags().StringVar(&to, "to", "", "recipient")
cmd.Flags().StringVar(&from, "from", "", "from address (default mailbox)")
cmd.Flags().StringVar(&subject, "subject", "", "override subject")
cmd.Flags().StringVar(&body, "body", "", "override plain-text body")
return cmd
}
func formatInvoiceCostEUR(v any) string {
s := strings.TrimSpace(fmt.Sprint(v))
s = strings.TrimSuffix(s, ".00")
s = strings.TrimSuffix(s, ".0")
if s == "" || s == "<nil>" {
return "?"
}
return s
}
func writeMailAttachment(path string, body []byte) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("attachment output path is required")
}
return os.WriteFile(path, body, 0o644)
}
func mailsSendCmd() *cobra.Command {
var from, to, cc, bcc, subject, body, html string
var id int64
cmd := &cobra.Command{
Use: "send",
Short: "Send a mail message (OnlyOffice Mail)",
Long: `Send via PUT /api/2.0/mail/messages/send.json.
oo mails send --id 7803 --body "…" # send referencing a draft id
oo mails send --to a@b.com --subject "…" --body "…"
oo mails send --id 7803 --to a@b.com --subject "…" --body "…" --cc x@y.com
IMPORTANT: send.json does NOT copy subject/body from the referenced draft — the
content must be in this request (--subject/--body). Cc/Bcc are omitted when empty
(the API 400s on empty strings). The API send does not append the UI signature —
put the chat line in --body if needed.
`,
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" && id == 0 {
return fmt.Errorf("--to is required (or --id of an existing draft)")
}
htmlBody := body
if html != "" {
htmlBody = html
}
if htmlBody == "" && id != 0 {
// The send.json endpoint does NOT copy subject/body from the
// referenced draft — an empty body here sends an empty message.
// Warn instead of silently mailing an empty email.
return fmt.Errorf("--body/--html is required when sending by --id (send.json needs the content in the request)")
}
if htmlBody == "" && to == "" {
return fmt.Errorf("--body is required for a fresh message")
}
c, err := newOO(cmd)
if err != nil {
return err
}
raw, err := c.SendMail(cmd.Context(), onlyoffice.SendMailParams{
ID: id,
From: from,
To: to,
Cc: cc,
Bcc: bcc,
Subject: subject,
Body: htmlBody,
})
if err != nil {
return err
}
fmt.Println(string(raw))
return nil
},
}
cmd.Flags().Int64Var(&id, "id", 0, "existing draft id to send (0 = fresh message)")
cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)")
cmd.Flags().StringVar(&to, "to", "", "recipient (required unless --id)")
cmd.Flags().StringVar(&cc, "cc", "", "cc")
cmd.Flags().StringVar(&bcc, "bcc", "", "bcc")
cmd.Flags().StringVar(&subject, "subject", "", "subject")
cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body")
cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)")
return cmd
}
func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete ID [ID...]",
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestWriteMailAttachment(t *testing.T) {
path := filepath.Join(t.TempDir(), "attach.bin")
body := []byte("payload")
if err := writeMailAttachment(path, body); err != nil {
t.Fatalf("writeMailAttachment: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != string(body) {
t.Fatalf("body = %q", got)
}
}
func TestWriteMailAttachmentRequiresPath(t *testing.T) {
if err := writeMailAttachment("", []byte("x")); err == nil {
t.Fatal("expected error for empty path")
}
}
+7 -4
View File
@@ -3,18 +3,21 @@
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
//
// oo calendar list | events | add | delete
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete|as-md|put-md)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add | dedupe-info
// oo contacts list | get | delete | info-add | merge | dedupe-info
// oo persons list | create | delete | dedupe
// oo companies list | create | delete | dedupe | dedupe-persons
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
// oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories
// oo crm cleanup
// oo mails accounts | folders | list | get | delete
// oo applications sync
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
// oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
// oo docs tools | convert | ocr | as-md | put-md
//
// CRM association rules: docs/crm-associations.md
//
// Every list supports `--output/-o json|table` (table is the default).
//
+35
View File
@@ -19,6 +19,7 @@ func init() {
opportunitiesCmd.AddCommand(oppListCmd())
opportunitiesCmd.AddCommand(oppGetCmd())
opportunitiesCmd.AddCommand(oppCreateCmd())
opportunitiesCmd.AddCommand(oppUpdateCmd())
opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd())
@@ -118,6 +119,40 @@ func oppCreateCmd() *cobra.Command {
return cmd
}
func oppUpdateCmd() *cobra.Command {
var title, desc string
var stage int64
var bid float64
cmd := &cobra.Command{
Use: "update OPPORTUNITY_ID",
Short: "Update opportunity title, description, stage, or bid",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.UpdateOpportunity(cmd.Context(), args[0], onlyoffice.UpdateOpportunityParams{
Title: title,
Description: desc,
StageID: stage,
BidValue: bid,
BidValueSet: cmd.Flags().Changed("bid"),
})
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "new title")
cmd.Flags().StringVar(&desc, "description", "", "new description")
cmd.Flags().Int64Var(&stage, "stage", 0, "pipeline stage id")
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
return cmd
}
func oppDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
+402 -1
View File
@@ -2,7 +2,11 @@ package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
@@ -19,9 +23,11 @@ func init() {
projectsCmd.AddCommand(prjListCmd())
projectsCmd.AddCommand(prjGetCmd())
projectsCmd.AddCommand(prjMilestonesCmd())
projectsCmd.AddCommand(prjMilestoneCreateCmd())
projectsCmd.AddCommand(prjCreateCmd())
projectsCmd.AddCommand(prjUpdateCmd())
projectsCmd.AddCommand(prjDeleteCmd())
projectsCmd.AddCommand(prjContactsCmd())
}
func prjListCmd() *cobra.Command {
@@ -114,19 +120,80 @@ func prjMilestonesCmd() *cobra.Command {
}
}
func prjMilestoneCreateCmd() *cobra.Command {
var deadline, desc string
var key bool
cmd := &cobra.Command{
Use: "milestone-create PROJECT_ID TITLE",
Short: "Create a project milestone (Gantt row)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
if deadline == "" {
return fmt.Errorf("--deadline YYYY-MM-DD is required")
}
day, err := time.Parse("2006-01-02", deadline)
if err != nil {
return fmt.Errorf("deadline: %w", err)
}
ms, err := c.CreateMilestone(onlyoffice.NewMilestoneRequest{
ProjectID: pid,
Title: args[1],
Deadline: onlyoffice.Time(day),
Description: desc,
IsKey: key,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt64(ms.ID),
"title": derefString(ms.Title),
})
return nil
},
}
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().BoolVar(&key, "key", false, "mark as key milestone")
return cmd
}
func prjCreateCmd() *cobra.Command {
var desc, resp string
var country, company string
cmd := &cobra.Command{
Use: "create TITLE",
Short: "Create a new project",
Long: `Create a project. Prefer canonical titles:
CC | Company | Title
Examples:
oo projects create "Mapbender" --country DE --company "Stadt Mainz"
oo projects create "DE | Acme | Geo Engineer"
When --country and --company are set, TITLE is only the third segment and the
full title is composed as "CC | Company | Title".`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
title := args[0]
if country != "" || company != "" {
title = onlyoffice.FormatProjectTitle(country, company, args[0])
}
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
Title: args[0],
Title: title,
Description: desc,
ResponsibleID: resp,
})
@@ -142,6 +209,8 @@ func prjCreateCmd() *cobra.Command {
}
cmd.Flags().StringVar(&desc, "description", "", "project description")
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
cmd.Flags().StringVar(&country, "country", "", "country/region code (DE, TF, UA, …)")
cmd.Flags().StringVar(&company, "company", "", "CRM company / engagement org name")
return cmd
}
@@ -160,6 +229,25 @@ func prjUpdateCmd() *cobra.Command {
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
// OO update requires responsibleId; reuse current when not passed.
if resp == "" {
cur, gerr := c.GetProjectByID(cmd.Context(), args[0])
if gerr != nil {
return gerr
}
if r, ok := cur["responsible"].(map[string]any); ok {
resp = fmt.Sprint(r["id"])
}
if resp == "" || resp == "<nil>" {
resp = strings.TrimSpace(fmt.Sprint(cur["responsibleId"]))
}
if title == "" {
title = strings.TrimSpace(fmt.Sprint(cur["title"]))
}
if desc == "" {
desc = strings.TrimSpace(fmt.Sprint(cur["description"]))
}
}
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
@@ -173,6 +261,11 @@ func prjUpdateCmd() *cobra.Command {
"id": derefInt(p.ID),
"title": p.String(),
})
// Confirm via GET — some OO builds return an empty body on PUT.
got, gerr := c.GetProjectByID(cmd.Context(), args[0])
if gerr == nil && got != nil {
printObject(map[string]any{"confirmed_title": got["title"], "confirmed_id": got["id"]})
}
return nil
},
}
@@ -216,6 +309,314 @@ func derefString(p *string) string {
return *p
}
func prjContactsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "contacts",
Short: "CRM contacts linked to a project (companies + persons)",
Long: `OnlyOffice Projects have two participant layers:
- Team (portal users) — ProjectTeam.aspx
- Contacts (CRM companies/persons) — linked via this API
Link employers/clients and people who worked on the engagement (e.g. git authors).`,
}
cmd.AddCommand(prjContactsListCmd())
cmd.AddCommand(prjContactsAddCmd())
cmd.AddCommand(prjContactsRemoveCmd())
cmd.AddCommand(prjContactsLinkGitCmd())
cmd.AddCommand(prjContactsLinkAuthorsCmd())
return cmd
}
func prjContactsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list PROJECT_ID",
Short: "List CRM contacts linked to the project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
list, err := c.ListProjectContacts(cmd.Context(), pid)
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(list))
for _, row := range list {
rows = append(rows, map[string]any{
"id": row["id"],
"name": row["displayName"],
"company": row["isCompany"],
"companyOf": row["companyName"],
})
}
printTable([]string{"id", "name", "company", "companyOf"}, rows)
return nil
},
}
}
func prjContactsAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add PROJECT_ID CONTACT_ID [CONTACT_ID...]",
Short: "Link CRM contact(s) to the project",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
for _, raw := range args[1:] {
cid, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("contact id %q: %w", raw, err)
}
if _, err := c.AddProjectContact(cmd.Context(), pid, cid); err != nil {
return fmt.Errorf("add %d: %w", cid, err)
}
printObject(map[string]any{"project_id": pid, "contact_id": cid, "linked": true})
}
return nil
},
}
}
func prjContactsRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove PROJECT_ID CONTACT_ID [CONTACT_ID...]",
Short: "Unlink CRM contact(s) from the project",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
for _, raw := range args[1:] {
cid, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("contact id %q: %w", raw, err)
}
if err := c.RemoveProjectContact(cmd.Context(), pid, cid); err != nil {
return fmt.Errorf("remove %d: %w", cid, err)
}
printObject(map[string]any{"project_id": pid, "contact_id": cid, "unlinked": true})
}
return nil
},
}
}
func prjContactsLinkGitCmd() *cobra.Command {
var gitRoot string
var minCommits int
var dryRun bool
var companyIDs []int
cmd := &cobra.Command{
Use: "link-git PROJECT_ID",
Short: "Link CRM persons matched from git shortlog (+ optional companies)",
Long: `Runs git shortlog -sne --all in --git-root, matches author emails to CRM
persons (FindPersonByEmail), and links found contacts to the project.
Does not create new persons — create/link them in CRM first.
Also links --company-id contacts (employer and/or client company).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if gitRoot == "" && len(companyIDs) == 0 {
return fmt.Errorf("need --git-root and/or --company-id")
}
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
var authors []gitAuthor
if gitRoot != "" {
authors, err = gitShortlogAuthors(gitRoot, minCommits)
if err != nil {
return err
}
}
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
},
}
cmd.Flags().StringVar(&gitRoot, "git-root", "", "local git repository path")
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
return cmd
}
func prjContactsLinkAuthorsCmd() *cobra.Command {
var authorsFile string
var minCommits int
var dryRun bool
var companyIDs []int
cmd := &cobra.Command{
Use: "link-authors PROJECT_ID",
Short: "Link CRM persons from a git shortlog file (+ companies)",
Long: `Same as link-git, but reads authors from a file produced by:
git shortlog -sne --all > authors.txt
Then: oo projects contacts link-authors 59 --from authors.txt --company-id COMPANY_ID`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if authorsFile == "" && len(companyIDs) == 0 {
return fmt.Errorf("need --from and/or --company-id")
}
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
var authors []gitAuthor
if authorsFile != "" {
b, err := os.ReadFile(authorsFile)
if err != nil {
return err
}
authors = parseShortlog(string(b), minCommits)
}
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
},
}
cmd.Flags().StringVar(&authorsFile, "from", "", "path to git shortlog -sne output")
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
return cmd
}
func linkProjectPeople(cmd *cobra.Command, c *onlyoffice.Client, pid int, authors []gitAuthor, companyIDs []int, dryRun bool) error {
ctx := cmd.Context()
existing, err := c.ListProjectContacts(ctx, pid)
if err != nil {
return err
}
have := map[int]struct{}{}
for _, row := range existing {
have[int(flexIDFloat(row["id"]))] = struct{}{}
}
linked, skipped := 0, 0
for _, cid := range companyIDs {
if _, ok := have[cid]; ok {
skipped++
continue
}
if dryRun {
printObject(map[string]any{"would_link_company": cid})
linked++
continue
}
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
return fmt.Errorf("company %d: %w", cid, err)
}
have[cid] = struct{}{}
linked++
printObject(map[string]any{"linked_company": cid})
}
for _, a := range authors {
p, err := c.FindPersonByEmail(ctx, a.Email)
if err != nil {
return err
}
if p == nil {
printObject(map[string]any{
"skipped_email": a.Email, "name": a.Name, "commits": a.Commits, "reason": "no_crm_person",
})
skipped++
continue
}
cid := int(flexIDFloat(p["id"]))
if _, ok := have[cid]; ok {
skipped++
continue
}
if dryRun {
printObject(map[string]any{"would_link_person": cid, "email": a.Email, "commits": a.Commits})
linked++
continue
}
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
return fmt.Errorf("person %d (%s): %w", cid, a.Email, err)
}
have[cid] = struct{}{}
linked++
printObject(map[string]any{"linked_person": cid, "email": a.Email, "commits": a.Commits})
}
printObject(map[string]any{"project_id": pid, "linked": linked, "skipped": skipped, "dry_run": dryRun})
return nil
}
func parseShortlog(text string, minCommits int) []gitAuthor {
var authors []gitAuthor
seen := map[string]struct{}{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
n, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
if n < minCommits {
continue
}
rest := parts[1]
lt := strings.LastIndex(rest, "<")
gt := strings.LastIndex(rest, ">")
if lt < 0 || gt <= lt {
continue
}
email := strings.ToLower(strings.TrimSpace(rest[lt+1 : gt]))
name := strings.TrimSpace(rest[:lt])
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
authors = append(authors, gitAuthor{Commits: n, Name: name, Email: email})
}
return authors
}
type gitAuthor struct {
Commits int
Name string
Email string
}
func gitShortlogAuthors(root string, minCommits int) ([]gitAuthor, error) {
cmd := exec.Command("git", "-C", root, "shortlog", "-sne", "--all")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git shortlog in %s: %w", root, err)
}
return parseShortlog(string(out), minCommits), nil
}
func derefInt(p *int) int {
if p == nil {
return 0
+90
View File
@@ -24,9 +24,43 @@ func projectFilesCmd() *cobra.Command {
cmd.AddCommand(prjFilesDownloadCmd())
cmd.AddCommand(prjFilesRenameCmd())
cmd.AddCommand(prjFilesDeleteCmd())
cmd.AddCommand(prjFilesDedupeCmd())
// Convenience aliases into oo docs (md↔docx / OCR pipeline).
cmd.AddCommand(aliasDocsAsMD())
cmd.AddCommand(aliasDocsPutMD())
cmd.AddCommand(aliasDocsPutTxt())
cmd.AddCommand(aliasDocsPutXlsx())
return cmd
}
func aliasDocsAsMD() *cobra.Command {
c := docsAsMDCmd()
c.Use = "as-md FILE_ID"
c.Short = "Alias of `oo docs as-md` — download OO file as Markdown (OCR if needed)"
return c
}
func aliasDocsPutMD() *cobra.Command {
c := docsPutMDCmd()
c.Use = "put-md PROJECT_ID MARKDOWN_PATH"
c.Short = "Alias of `oo docs put-md` — Markdown→DOCX upload into project"
return c
}
func aliasDocsPutTxt() *cobra.Command {
c := docsPutTxtCmd()
c.Use = "put-txt PROJECT_ID TEXT_PATH"
c.Short = "Alias of `oo docs put-txt` — plain text→DOCX upload into project"
return c
}
func aliasDocsPutXlsx() *cobra.Command {
c := docsPutXlsxCmd()
c.Use = "put-xlsx PROJECT_ID [LOCAL_XLSX]"
c.Short = "Alias of `oo docs put-xlsx` — generate/upload XLSX with formulas"
return c
}
func prjFilesListCmd() *cobra.Command {
var showFolders bool
cmd := &cobra.Command{
@@ -184,6 +218,62 @@ func prjFilesDeleteCmd() *cobra.Command {
}
}
func prjFilesDedupeCmd() *cobra.Command {
var apply, cross bool
cmd := &cobra.Command{
Use: "dedupe PROJECT_ID",
Short: "Find (and optionally remove) duplicate files in project Documents folders",
Long: `Duplicates share the same logical name: stem|ext (OO title+fileExst).
Default: dry-run report. Pass --apply to delete older copies (keeps newest; --cross prefers non-trash folders).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
groups, deleted, err := c.DedupeProject(cmd.Context(), args[0], onlyoffice.DedupOptions{
CrossFolder: cross,
}, apply)
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(groups))
for _, g := range groups {
row := map[string]any{
"key": g.Key,
"folder_id": g.FolderID,
"folder_title": g.FolderTitle,
"keep_id": fileIDStr(g.Keep),
"keep_title": onlyoffice.FileEntryTitle(g.Keep),
"remove_count": len(g.Remove),
}
removeIDs := make([]string, 0, len(g.Remove))
for _, f := range g.Remove {
removeIDs = append(removeIDs, fileIDStr(f))
}
row["remove_ids"] = removeIDs
rows = append(rows, row)
}
out := map[string]any{
"project_id": args[0],
"dry_run": !apply,
"cross": cross,
"groups": len(groups),
"duplicates": rows,
}
if apply {
out["deleted_ids"] = deleted
}
printObject(out)
return nil
},
}
cmd.Flags().BoolVar(&apply, "apply", false, "delete duplicate files (default: report only)")
cmd.Flags().BoolVar(&cross, "cross", false, "also dedupe same stem|ext across folders (prefers non-_trash)")
return cmd
}
func fileEntryRows(files []*onlyoffice.FileEntry) []map[string]any {
rows := make([]map[string]any, 0, len(files))
for _, f := range files {
+55 -3
View File
@@ -2,7 +2,11 @@ package main
import (
"fmt"
"os"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
@@ -82,11 +86,12 @@ func taskGetCmd() *cobra.Command {
}
func taskCreateCmd() *cobra.Command {
var project, desc, deadline, prio string
var project, desc, deadline, start, prio string
var milestone int64
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a project task",
Short: "Create a project task (Gantt bar when --start/--deadline/--milestone set)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
@@ -100,6 +105,51 @@ func taskCreateCmd() *cobra.Command {
case "low":
p = -1
}
if start != "" || milestone != 0 {
pid, err := strconv.Atoi(project)
if err != nil && project != "" {
return fmt.Errorf("project id: %w", err)
}
if project == "" {
pid, err = strconv.Atoi(os.Getenv("OO_PROJECT_ID"))
if err != nil {
return fmt.Errorf("--project or OO_PROJECT_ID required")
}
}
req := onlyoffice.NewProjectTaskRequest{
ProjectId: pid,
Title: args[0],
Description: desc,
Priority: p,
MilestoneId: int(milestone),
}
if deadline == "" {
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02")
}
d, err := time.Parse("2006-01-02", deadline)
if err != nil {
return fmt.Errorf("deadline: %w", err)
}
req.Deadline = onlyoffice.Time(d)
if start != "" {
s, err := time.Parse("2006-01-02", start)
if err != nil {
return fmt.Errorf("start: %w", err)
}
req.StartDate = onlyoffice.Time(s)
} else {
req.StartDate = onlyoffice.Time(time.Now())
}
task, err := c.CreateProjectTask(req)
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(task.ID),
"title": derefString(task.Title),
})
return nil
}
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
if err != nil {
return err
@@ -110,7 +160,9 @@ func taskCreateCmd() *cobra.Command {
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d)")
cmd.Flags().StringVar(&start, "start", "", "start YYYY-MM-DD (uses CreateProjectTask / Gantt)")
cmd.Flags().Int64Var(&milestone, "milestone", 0, "milestone id (Gantt row)")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd
}
+96
View File
@@ -0,0 +1,96 @@
package onlyoffice
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
)
// History entities that OnlyOffice CRM actually accepts for history notes.
// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json
// returns 400 "Value does not fall within the expected range." for entityType
// contact/person/people/client/member. Verified against a live instance (#74).
const (
HistoryEntityOpportunity = "opportunity"
HistoryEntityCase = "case"
)
// IsCompany reports whether a CRM contact row is a company (vs a person).
// The field arrives as JSON bool; be liberal about what we accept.
func IsCompany(person map[string]any) bool {
b, _ := person["isCompany"].(bool)
return b
}
// ContactID returns the CRM id of a contact row as a plain string.
func ContactID(row map[string]any) string {
return fmt.Sprint(row["id"])
}
// BuildContactEmailIndex scans all persons once and maps lowercase email →
// contact id. Use this instead of calling FindPersonByEmail per address:
// the index is O(N) over the whole CRM, the per-address lookup is O(N×M).
func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) {
all, err := c.ListAllContacts(ctx)
if err != nil {
return nil, err
}
index := make(map[string]string, len(all)*2)
for _, person := range all {
if IsCompany(person) {
continue
}
id := ContactID(person)
for _, row := range ContactInfoRows(person) {
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
continue
}
email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"])))
if email != "" && email != "<nil>" {
index[email] = id
}
}
}
return index, nil
}
// BuildPersonOpportunityIndex maps every opportunity member's contact id to a
// deterministic representative opportunity: the one with the lowest numeric id.
// OnlyOffice has no person-level history, so notes for a person go on their
// deal — this index answers "which deal" in one pass.
func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) {
opps, err := c.ListAllOpportunities(ctx)
if err != nil {
return nil, err
}
index := map[string]string{}
for _, opp := range opps {
oppID := ContactID(opp)
for _, member := range OpportunityMembers(opp) {
pid := ContactID(member)
if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) {
index[pid] = oppID
}
}
}
return index, nil
}
// NumericIDLess compares two string ids numerically when possible, falling
// back to lexicographic order so results stay deterministic either way.
func NumericIDLess(a, b string) bool {
na, errA := strconv.Atoi(strings.TrimSpace(a))
nb, errB := strconv.Atoi(strings.TrimSpace(b))
if errA == nil && errB == nil && na != nb {
return na < nb
}
return a < b
}
// SortIDs orders id strings deterministically (numeric first, then lexical).
func SortIDs(ids []string) {
sort.Strings(ids)
sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) })
}
+65
View File
@@ -0,0 +1,65 @@
package onlyoffice
import "testing"
func TestIsCompany(t *testing.T) {
if IsCompany(map[string]any{"isCompany": true}) != true {
t.Fatal("true row not detected")
}
if IsCompany(map[string]any{"isCompany": false}) {
t.Fatal("false row detected as company")
}
if IsCompany(map[string]any{}) {
t.Fatal("missing field detected as company")
}
if IsCompany(nil) {
t.Fatal("nil row detected as company")
}
}
func TestNumericIDLess(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"9", "10", true},
{"1747", "1748", true},
{"abc", "abd", true},
{"10", "9", false},
{" 12 ", "13", true},
{"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2"
}
for _, c := range cases {
if got := NumericIDLess(c.a, c.b); got != c.want {
t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want)
}
}
}
func TestSortIDs(t *testing.T) {
ids := []string{"20", "3", "100", "1"}
SortIDs(ids)
want := "1 3 20 100"
got := ""
for i, id := range ids {
if i > 0 {
got += " "
}
got += id
}
if got != want {
t.Fatalf("SortIDs=%q want %q", got, want)
}
}
func TestContactID(t *testing.T) {
if ContactID(map[string]any{"id": float64(42)}) != "42" {
t.Fatal("numeric id formatting broken")
}
}
func TestHistoryEntityConstants(t *testing.T) {
if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" {
t.Fatal("history entity whitelist drifted from live-verified values")
}
}
+203 -2
View File
@@ -2,7 +2,7 @@ package onlyoffice
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
// These expose untyped maps for flexibility — they are primarily consumed by
// cmd/oo and the applications-sync workflow.
// cmd/oo and CRM sync tooling.
import (
"context"
@@ -11,6 +11,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
)
// ListContacts returns a page of CRM contacts and the total count.
@@ -45,9 +46,12 @@ func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]a
}
// FindCompany searches for a company contact with an exact (case-insensitive)
// displayName match. Returns nil when not found.
// displayName match after slogan/legal-suffix normalization. Returns nil when not found.
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
needle := CompanyGroupingKey(name)
if needle == "" {
return nil, nil
}
const page = 50
for start := 0; ; start += page {
items, total, err := c.ListContacts(ctx, page, start, name)
@@ -66,6 +70,19 @@ func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any,
break
}
}
// Search filter may miss longer legal names; fall back to a full company scan.
all, err := c.ListAllContacts(ctx)
if err != nil {
return nil, err
}
for _, co := range all {
if !isCompany(co) {
continue
}
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
return co, nil
}
}
return nil, nil
}
@@ -95,6 +112,52 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string
return nil, nil
}
// FindPersonByEmail finds a person whose primary or contact-info email matches
// (case-insensitive). Companies are skipped. Returns nil when not found.
func (c *Client) FindPersonByEmail(ctx context.Context, email string) (map[string]any, error) {
needle := strings.ToLower(strings.TrimSpace(email))
if needle == "" {
return nil, nil
}
all, err := c.ListAllContacts(ctx)
if err != nil {
return nil, err
}
for _, p := range all {
if isCompany(p) {
continue
}
for _, em := range contactEmailValues(p) {
if strings.ToLower(strings.TrimSpace(em)) == needle {
return p, nil
}
}
}
return nil, nil
}
func contactEmailValues(p map[string]any) []string {
var out []string
for _, key := range []string{"email", "primaryEmail"} {
if v := strings.TrimSpace(fmt.Sprint(p[key])); v != "" && v != "<nil>" {
out = append(out, v)
}
}
for _, row := range ContactInfoRows(p) {
if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" {
continue
}
data := strings.TrimSpace(fmt.Sprint(row["data"]))
if data == "" || data == "<nil>" {
data = strings.TrimSpace(fmt.Sprint(row["value"]))
}
if data != "" {
out = append(out, data)
}
}
return out
}
func isCompany(m map[string]any) bool {
v, ok := m["isCompany"].(bool)
return ok && v
@@ -124,6 +187,44 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
}
// UpdatePerson updates first/last name and optional company link on a person.
// companyID == 0 leaves the company association unchanged (re-sends current
// company id when present). OnlyOffice ignores companyId/about on form-encoded
// PUT and may unlink the company when companyId is omitted — use JSON body.
func (c *Client) UpdatePerson(ctx context.Context, personID, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
body := map[string]any{
"firstName": first,
"lastName": last,
}
if companyID != 0 {
body["companyId"] = companyID
} else {
// Preserve existing employer: omitted companyId unlinks on this API.
if cur, err := c.GetContact(ctx, personID); err == nil {
if co, ok := cur["company"].(map[string]any); ok {
if id := flexInt(co["id"]); id != 0 {
body["companyId"] = id
}
}
}
}
if jobTitle != "" {
body["jobTitle"] = jobTitle
}
if about != "" {
body["about"] = about
}
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/person/%s.json", url.PathEscape(personID)), body)
if err != nil {
return out, err
}
// PUT response body is often stale; re-fetch for authoritative fields.
if fresh, gerr := c.GetContact(ctx, personID); gerr == nil && fresh != nil {
out = fresh
}
return out, nil
}
// AddContactInfo attaches an email/website/phone/etc. to a contact.
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
if category == "" {
@@ -142,6 +243,69 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
}
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
}
// CreateContactTag creates a contact tag by name. Idempotent: "already exists" is OK.
func (c *Client) CreateContactTag(ctx context.Context, tagName string) error {
fields := url.Values{}
fields.Set("tagName", tagName)
_, err := c.postFormObject(ctx, "/api/2.0/crm/contact/tag.json", fields)
if err != nil && strings.Contains(err.Error(), "already exists") {
return nil
}
return err
}
// AddContactTag attaches tagName to a contact.
func (c *Client) AddContactTag(ctx context.Context, contactID, tagName string) error {
fields := url.Values{}
fields.Set("tagName", tagName)
_, err := c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
return err
}
// RemoveContactTag removes tagName from a contact.
func (c *Client) RemoveContactTag(ctx context.Context, contactID, tagName string) error {
fields := url.Values{}
fields.Set("tagName", tagName)
raw, err := c.deleteForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/tag.json", url.PathEscape(contactID)), fields)
if err != nil {
return err
}
_, err = unmarshalResponseObject(raw)
return err
}
// ListContactsByTag returns contacts filtered by a single CRM tag name.
func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, startIndex int) ([]map[string]any, int, error) {
if count <= 0 {
count = 50
}
q := url.Values{}
q.Set("count", strconv.Itoa(count))
q.Set("startIndex", strconv.Itoa(startIndex))
q.Set("tags", tagName)
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
if err != nil {
return nil, 0, err
}
var env struct {
Response []map[string]any `json:"response"`
Total int `json:"total"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, 0, err
}
total := env.Total
if total == 0 && len(env.Response) > 0 {
total = len(env.Response)
}
return env.Response, total, nil
}
// ListAllContacts paginates through every CRM contact.
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
const page = 100
@@ -491,11 +655,25 @@ func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map
}
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
// Empty deadline defaults to now+14d (API requires a deadline). Empty
// responsibleID falls back to the authenticated user — without this the
// portal assigns a stub "Profile has been removed" owner.
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
if deadline == "" {
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
}
if categoryID == 0 {
categoryID = 2 // Opportunity (default CRM task category)
}
uid, err := c.SelfUserID(ctx)
if err != nil {
return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err)
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("deadline", deadline)
fields.Set("categoryId", strconv.Itoa(categoryID))
fields.Set("responsibleId", uid)
if contactID != 0 {
fields.Set("contactId", strconv.Itoa(contactID))
}
@@ -511,6 +689,29 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
}
// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task.
// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged
// by the API only if omitted — callers should pass deadLine when known.
func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) {
if responsibleID == "" {
uid, err := c.SelfUserID(ctx)
if err != nil {
return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err)
}
responsibleID = uid
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("responsibleId", responsibleID)
if deadline != "" {
fields.Set("deadline", deadline)
}
if categoryID != 0 {
fields.Set("categoryId", strconv.Itoa(categoryID))
}
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields)
}
// DeleteCRMTask removes a CRM task by id.
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
+5
View File
@@ -227,6 +227,11 @@ func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[st
continue
}
cid := flexInt(row["companyId"])
if cid == 0 {
if co, ok := row["company"].(map[string]any); ok {
cid = flexInt(co["id"])
}
}
if cid == 0 {
continue
}
+172
View File
@@ -0,0 +1,172 @@
package onlyoffice
import (
"regexp"
"strings"
"unicode"
)
var (
parenSuffixRE = regexp.MustCompile(`(?i)\s*[\(\[\{][^)\]\}]*[\)\]\}]\s*$`)
dashCompanyRE = regexp.MustCompile(`(?i)\s+[-–—]\s+[A-Za-z0-9].*$`)
emailLocalRE = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
nonNameTokenRE = regexp.MustCompile(`[^a-zA-ZÀ-öø-ÿĀ-ž0-9'’.\-]+`)
)
// CleanPersonNames strips company annotations from display names and fills
// first/last from email local-part when the source used an address as the name.
// Company affiliation belongs on CRM companyId — never in LastName.
func CleanPersonNames(first, last, display, org string, emails []string) (cleanFirst, cleanLast string) {
first = strings.TrimSpace(first)
last = strings.TrimSpace(last)
display = strings.TrimSpace(display)
org = strings.TrimSpace(org)
if looksLikeEmail(first) {
ef, el := GuessNameFromEmail(first)
first, last = ef, el
}
if looksLikeEmail(display) && first == "" && last == "" {
display = ""
}
if first == "" && last == "" && display != "" {
first, last = SplitDisplayName(display)
}
first = stripCompanyAnnotation(first, org)
last = stripCompanyAnnotation(last, org)
last = stripCompanyAnnotation(last, org)
if i := strings.IndexAny(first, "(["); i > 0 {
first = strings.TrimSpace(first[:i])
}
if org != "" && personLastIsOrg(last, org) {
last = ""
}
if (first == "" || looksLikeEmail(first)) && len(emails) > 0 {
ef, el := GuessNameFromEmail(emails[0])
if first == "" || looksLikeEmail(first) {
first = ef
}
if last == "" || last == "-" {
last = el
}
}
first = strings.TrimSpace(first)
last = strings.TrimSpace(last)
if last == "" {
last = "-"
}
return first, last
}
func stripCompanyAnnotation(s, org string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = parenSuffixRE.ReplaceAllString(s, "")
s = strings.TrimSpace(s)
s = dashCompanyRE.ReplaceAllString(s, "")
s = strings.TrimSpace(s)
if org != "" {
for _, sep := range []string{" - ", " – ", " — ", " / "} {
if i := strings.LastIndex(strings.ToLower(s), strings.ToLower(sep+org)); i >= 0 {
s = strings.TrimSpace(s[:i])
}
}
suf := " (" + org + ")"
if strings.HasSuffix(strings.ToLower(s), strings.ToLower(suf)) {
s = strings.TrimSpace(s[:len(s)-len(suf)])
}
}
return strings.TrimSpace(s)
}
func personLastIsOrg(last, org string) bool {
last = NormalizePersonNameKey(last)
org = NormalizePersonNameKey(org)
if last == "" || org == "" {
return false
}
if last == org {
return true
}
return strings.HasPrefix(org, last+" ") || strings.HasPrefix(org, last+",")
}
func looksLikeEmail(s string) bool {
return emailLocalRE.MatchString(strings.TrimSpace(s))
}
// GuessNameFromEmail turns local@domain into Title-Case first/last when the
// local part looks like first.last / first_last / first-last.
func GuessNameFromEmail(email string) (first, last string) {
email = NormalizeContactEmail(email)
local, _, ok := strings.Cut(email, "@")
if !ok || local == "" {
return "", ""
}
local = strings.Split(local, "+")[0]
parts := strings.FieldsFunc(local, func(r rune) bool {
return r == '.' || r == '_' || r == '-'
})
if len(parts) == 0 {
return titleNameToken(local), ""
}
if len(parts) == 1 {
return titleNameToken(parts[0]), ""
}
return titleNameToken(parts[0]), titleNameToken(strings.Join(parts[1:], " "))
}
func titleNameToken(s string) string {
s = nonNameTokenRE.ReplaceAllString(s, " ")
s = strings.TrimSpace(s)
if s == "" {
return ""
}
runes := []rune(strings.ToLower(s))
runes[0] = unicode.ToTitle(runes[0])
return string(runes)
}
// FormatProjectTitle builds "CC | Company | Title" (spaces around |).
// Country should be a short code (DE, TF, UA, …). Empty segments are dropped.
func FormatProjectTitle(country, company, title string) string {
parts := make([]string, 0, 3)
for _, p := range []string{country, company, title} {
p = strings.TrimSpace(p)
p = strings.ReplaceAll(p, "|", "/")
if p != "" {
parts = append(parts, p)
}
}
return strings.Join(parts, " | ")
}
// NormalizeContactEmail lowercases and trims.
func NormalizeContactEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// NormalizePersonNameKey collapses whitespace and lowercases for matching.
func NormalizePersonNameKey(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:], " ")
}
+39
View File
@@ -0,0 +1,39 @@
package onlyoffice
import "testing"
func TestCleanPersonNames_stripsCompanyParen(t *testing.T) {
f, l := CleanPersonNames("Jane", "Doe (Acme)", "Jane Doe (Acme)", "Acme", nil)
if f != "Jane" || l != "Doe" {
t.Fatalf("got %q %q", f, l)
}
}
func TestCleanPersonNames_stripsDashCompany(t *testing.T) {
f, l := CleanPersonNames("Jane", "Doe - Acme", "", "Acme", nil)
if f != "Jane" || l != "Doe" {
t.Fatalf("got %q %q", f, l)
}
}
func TestCleanPersonNames_fromEmail(t *testing.T) {
f, l := CleanPersonNames("jane.doe@example.com", "-", "", "Acme",
[]string{"jane.doe@example.com"})
if f != "Jane" || l != "Doe" {
t.Fatalf("got %q %q", f, l)
}
}
func TestCleanPersonNames_lastIsCompany(t *testing.T) {
f, l := CleanPersonNames("Jane", "Acme", "", "Acme GmbH & Co. KG", nil)
if f != "Jane" || l != "-" {
t.Fatalf("got %q %q", f, l)
}
}
func TestFormatProjectTitle(t *testing.T) {
got := FormatProjectTitle("DE", "Acme", "Map App")
if got != "DE | Acme | Map App" {
t.Fatalf("got %q", got)
}
}
+66 -4
View File
@@ -10,6 +10,9 @@ var multiSpace = regexp.MustCompile(`\s+`)
// sloganSeparators split a company name from a trailing tagline/slogan.
var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"}
// companyLegalSuffix matches trailing legal-form tokens (GmbH, & Co. KG, …).
var companyLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(&\s*co\.?\s*kg\.?|co\.?\s*kg\.?|gmbh|ag|ltd\.?|llc\.?|inc\.?|plc\.?|s\.?l\.?|sarl|b\.?v\.?)\s*$`)
// StripSloganSuffix returns the part before an em/en dash tagline, e.g.
// "Affirm — Fraud Engineering" → "Affirm".
func StripSloganSuffix(s string) string {
@@ -22,9 +25,23 @@ func StripSloganSuffix(s string) string {
return s
}
// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans).
// StripCompanyLegalSuffix removes trailing GmbH / & Co. KG / Ltd / … tokens.
func StripCompanyLegalSuffix(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ",; ")
for {
next := companyLegalSuffix.ReplaceAllString(s, "")
next = strings.TrimRight(strings.TrimSpace(next), ",; ")
if next == s || next == "" {
return s
}
s = next
}
}
// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans and legal suffixes).
func CompanyGroupingKey(s string) string {
return NormalizeCompanyName(StripSloganSuffix(s))
return NormalizeCompanyName(StripCompanyLegalSuffix(StripSloganSuffix(s)))
}
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
@@ -52,7 +69,7 @@ func StripCompanySuffix(title string) string {
}
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
// empty-position titles like " @ contoso" to "contoso".
// empty-position titles like " @ Acme" to "Acme".
func FixDealTitle(s string) string {
s = strings.TrimSpace(s)
for strings.HasPrefix(s, "@") {
@@ -75,8 +92,53 @@ func FixDealTitle(s string) string {
}
// ContactInfoKey groups contact info rows by type and normalized value.
// ContactInfoKey builds a dedupe key for a contact-info row.
func ContactInfoKey(infoType, value string) string {
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
return NormalizeContactInfoType(infoType) + "|" + strings.ToLower(strings.TrimSpace(value))
}
// NormalizeContactInfoType maps OO numeric infoType codes and string labels to
// a canonical lowercase label (email, phone, …).
func NormalizeContactInfoType(infoType string) string {
t := strings.ToLower(strings.TrimSpace(infoType))
switch t {
case "1", "email":
return "email"
case "2", "phone":
return "phone"
case "3", "website":
return "website"
case "4", "skype":
return "skype"
case "5", "twitter":
return "twitter"
case "6", "linkedin":
return "linkedin"
case "7", "facebook":
return "facebook"
case "8", "address":
return "address"
case "9", "livejournal":
return "livejournal"
case "10", "myspace":
return "myspace"
case "11", "gmail":
return "gmail"
case "12", "blogger":
return "blogger"
case "13", "yahoo":
return "yahoo"
case "14", "msn":
return "msn"
case "15", "icq":
return "icq"
case "16", "jabber":
return "jabber"
case "17", "aim":
return "aim"
default:
return t
}
}
// MemberDisplayKey normalizes a member displayName for duplicate detection.
+4
View File
@@ -65,6 +65,10 @@ func TestCompanyGroupingKey(t *testing.T) {
if a != b {
t.Fatalf("%q != %q", a, b)
}
if CompanyGroupingKey("Acme") != CompanyGroupingKey("Acme GmbH & Co. KG,") {
t.Fatalf("legal suffix should not change grouping key: %q vs %q",
CompanyGroupingKey("Acme"), CompanyGroupingKey("Acme GmbH & Co. KG,"))
}
}
func TestOpportunityTitlesMatchSlogan(t *testing.T) {
+124
View File
@@ -0,0 +1,124 @@
# CRM associations (company ↔ person ↔ deal ↔ project ↔ invoice ↔ mail)
Operational rules for the `oo` CLI and this library. Business SSOT remains
OnlyOffice Workspace CRM + Projects.
## Canonical graph
One **legal company** owns the relationship. Do not invent a second “bill-to”
company just for PDF layout.
```text
Company
├── Person (buyer contact) oo persons create --company-id
├── Opportunity / Deal oo opportunities … ; member-add company + person
├── Project (hub) oo projects … ; contacts add company + person
│ └── Epic + subtasks
└── Invoice (Draft → …) oo invoices create --contact COMPANY --opportunity DEAL
└── PDF file oo invoices pdf ID
└── Mail draft oo mails draft-invoice --invoice ID --to …
```
| Layer | CLI | Must link |
|-------|-----|-----------|
| Company | `oo companies create` | website, email, phone, **one** Billing address |
| Person | `oo persons create --company-id` / `oo persons update ID` | job title; never encode employer in `lastName`; **update uses JSON** (form PUT ignores `companyId`/`about`) |
| Deal | `oo opportunities create` + `member-add` | company **and** person as members |
| Project | `oo projects create` + `contacts add` | same company + person |
| Invoice | `oo invoices create --contact COMPANY --opportunity DEAL` | `entityId` at **create** |
| Mail | `oo mails draft-invoice` | attach current PDF; **do not send** until confirmed |
UI checks (same company card):
- `#contacts` → person
- `#deals` → opportunity
- `#projects` → hub project
- `#invoices` on the **deal** → invoice (needs `entity`)
- `#files` → preferably **one** current invoice PDF
**Project Team ≠ Project Contacts.** Team = portal users. CRM people/companies
show under the project **Contacts** tab (`oo projects contacts list`).
## Hard rules
1. **One company per legal entity.** Duplicate “bill-to” contacts empty Deals /
Projects / Contacts tabs and break merge. Prefer
`oo contacts merge FROM INTO` (keeps `INTO`) or `oo companies dedupe`.
2. **Link invoice → deal at create.**
`POST /crm/invoice` with `entityId` + `entityType: 0` (Opportunity).
`oo invoices update … --opportunity` often returns **400**
(“Value does not fall within the expected range”). If the link is missing,
delete the Draft and recreate with `--opportunity`.
3. **Bill To = company id**, not a throwaway contact. Person stays under the
company (`companyId`). Optional `consigneeId` for Empfänger when the portal
template prints it.
4. **Stay Draft until mail is ready.** Billed (`status id=2`) is **not editable**
via content PUT. Going Billed → Draft via `…/crm/invoice/status/1` usually
**does not work** — delete + recreate Draft instead.
5. **Do not regenerate PDF in a loop** without cleanup. Each
`GET …/crm/invoice/{id}/pdf` attaches a new file to the company (and often
the deal). Keep `invoice.fileID`; delete older PDFs with
`oo invoices pdf-cleanup ID` / Documents `fileops/delete`.
## Invoice PDF quirks
| Symptom | Workaround |
|---------|------------|
| Cached / stale PDF | Touch invoice (Draft PUT that clears `fileID`), then `GET …/pdf` — `oo invoices pdf ID --force` |
| Billing address missing on **new** PDFs | Temporary multiline `companyName` (`Line1\nLine2\n…`) on the **canonical** company → force PDF → restore clean name. Cached `fileID` keeps the multiline Bill To. |
| Separate bill-to company for newlines | **Forbidden** — merge back to the real company |
| Invoice **number** won’t change on PUT | Delete Draft and recreate with the desired number |
| Notizen / Bedingungen spacing | Leading `\n` and blank lines only — no HTML (tags print literally) |
| Issuer street lines | Organisation profile address (`street` with `\n`), not only terms |
Status ids commonly used: `1` Draft, `2` Billed, `3` Rejected, `4` Paid.
## Mail quirks
| Symptom | Workaround |
|---------|------------|
| Signature / body doubles chat URL | Put chat in **one** place only. UI drafts: signature. API send: body (API **does not** append signature). |
| Signature / body cuts URL at `#` | Plain text URLs — avoid `<a href="…#…">` (or encode `#` as `%23` in href) |
| German letter spacing | Blank `<p>&nbsp;</p>` between blocks (`MailHTMLWithBlankParagraphs`) |
| Send | `PUT /api/2.0/mail/messages/send.json` with `id/from/to/subject/body`; omit empty `cc`/`bcc`. Never auto-send; draft only until the human confirms |
Prefer OnlyOffice Mail (`/addons/mail/#drafts`) for invoice delivery until confirmed.
## Project / task quirks
- Hub title: `CC | Company` (e.g. `DE | Acme GmbH`).
- Streams = epics/tasks under the hub, not a third title segment (unless the
project itself is a named delivery stream).
- Closing a **subtask**:
`PUT /api/2.0/project/task/{epicId}/{subtaskId}/status` with `status=2`.
`oo tasks update SUBTASK -s closed` returns **404** for subtasks.
- After deleting a CRM contact, `GET /project/contact/{deletedId}` may still
return projects (ghost). Official project contact list should only show live
ids; unlink may 400 if the contact is gone.
## Merge / cleanup cheat sheet
```bash
# Keep the preferred company (INTO), drop the duplicate (FROM)
oo contacts merge FROM_ID INTO_ID
# Or by normalized name (careful — whole CRM)
oo companies dedupe
# Invoice ↔ deal must exist at create
oo invoices create --number P-YYYY-NN --contact COMPANY_ID --item ITEM_ID \
--price 300 --opportunity DEAL_ID --language de-DE …
# Fresh PDF + prune older PDFs on company/deal
oo invoices pdf INVOICE_ID --force
oo invoices pdf-cleanup INVOICE_ID
# Mail draft (no send)
oo mails draft-invoice --invoice INVOICE_ID --to billing@example.com
```
## Related
- README § invoices / mail / CRM cleanup
- Personal workspace tooling (disk inventory, dossier sync): private
`git.produktor.io/eSlider/oo-workspace` (`oow` CLI)
+95 -12
View File
@@ -237,6 +237,15 @@ func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath str
return decodeResponseFileEntry(raw)
}
// UploadProjectFileReplacing upserts by stem|ext in the project Documents folder.
func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, localPath string) (*FileEntry, []int, error) {
folderID, err := c.projectFolderID(ctx, projectID)
if err != nil {
return nil, nil, err
}
return c.UploadToFolderReplacing(ctx, folderID, localPath)
}
// GetFile returns file metadata including viewUrl for download.
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
if fileID == "" {
@@ -263,22 +272,86 @@ func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*File
return decodeResponseFileEntry(raw)
}
type deleteFilesBody struct {
FileIDs []int `json:"fileIds"`
FolderIDs []int `json:"folderIds"`
}
// DeleteFiles permanently deletes files by numeric id (Documents module).
// Uses per-file DELETE (DeleteDavItems); fileops/delete returns 200 on some
// portals (e.g. produktor.io) without removing the file.
func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
if len(fileIDs) == 0 {
return fmt.Errorf("no file ids to delete")
}
body := deleteFilesBody{FileIDs: fileIDs, FolderIDs: nil}
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/delete.json", body)
if err != nil {
_, err = c.putJSON(ctx, "/api/2.0/files/fileops/delete", body)
strIDs := make([]string, len(fileIDs))
for i, id := range fileIDs {
strIDs[i] = strconv.Itoa(id)
}
return err
return c.DeleteDavItems(ctx, nil, strIDs)
}
// ListFolder returns the Documents module listing for a folder id
// (GET /api/2.0/files/{folderId}).
func (c *Client) ListFolder(ctx context.Context, folderID string) (map[string]any, error) {
if folderID == "" {
return nil, fmt.Errorf("folder id is required")
}
out, err := c.ResponseObject(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+".json")
if err != nil {
out, err = c.ResponseObject(ctx, "/api/2.0/files/"+url.PathEscape(folderID))
}
return out, err
}
// CreateFolder creates a subfolder under parentFolderID.
func (c *Client) CreateFolder(ctx context.Context, parentFolderID, title string) (map[string]any, error) {
if parentFolderID == "" || title == "" {
return nil, fmt.Errorf("parent folder id and title are required")
}
body := map[string]any{"title": title}
out, err := c.postJSONObject(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentFolderID)+".json", body)
if err != nil {
out, err = c.postJSONObject(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentFolderID), body)
}
return out, err
}
// MoveFiles moves file ids into destFolderID (Documents fileops/move).
func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) (map[string]any, error) {
if destFolderID == 0 || len(fileIDs) == 0 {
return nil, fmt.Errorf("dest folder and file ids are required")
}
body := map[string]any{
"folderIds": []int{},
"fileIds": fileIDs,
"destFolderId": destFolderID,
}
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
if err != nil {
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
}
return out, err
}
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) (*FileEntry, error) {
if folderID == "" || localPath == "" {
return nil, fmt.Errorf("folder id and local path are required")
}
uploadPath := fmt.Sprintf("/api/2.0/files/%s/upload.json", url.PathEscape(folderID))
raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath)
if err != nil {
uploadPath = fmt.Sprintf("/api/2.0/files/%s/upload", url.PathEscape(folderID))
raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath)
if err != nil {
return nil, err
}
}
return decodeResponseFileEntry(raw)
}
// FileFolderID returns the parent folder id string for a file entry, if known.
func FileFolderID(f *FileEntry) string {
if f == nil || f.FolderID == nil {
return ""
}
return f.FolderID.String()
}
// DownloadFile streams file bytes from the file's viewUrl using the same auth
@@ -319,8 +392,18 @@ func (c *Client) resolveAPIURL(ref string) string {
if ref == "" {
return ref
}
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
return ref
// Rewrite any host to the configured API base so downloads stay on the
// internal network and keep the Authorization header (no cross-host
// redirect that would strip it). Scheme-relative URLs are handled too.
if strings.HasPrefix(ref, "//") {
ref = "http:" + ref
}
if u, err := url.Parse(ref); err == nil && u.IsAbs() {
if base, err2 := url.Parse(c.baseURL()); err2 == nil {
u.Scheme = base.Scheme
u.Host = base.Host
return u.String()
}
}
base := c.baseURL()
if strings.HasPrefix(ref, "/") {
+337
View File
@@ -0,0 +1,337 @@
package onlyoffice
import (
"context"
"path/filepath"
"sort"
"strings"
)
// FileEntryExt returns a normalized extension (lowercase, with leading dot).
func FileEntryExt(f *FileEntry) string {
if f == nil {
return ""
}
exst := ""
if f.FileExst != nil {
exst = strings.TrimSpace(*f.FileExst)
}
if exst != "" {
if !strings.HasPrefix(exst, ".") {
exst = "." + exst
}
return strings.ToLower(exst)
}
if f.Title != nil {
if ext := filepath.Ext(*f.Title); ext != "" {
return strings.ToLower(ext)
}
}
return ""
}
// FileDedupKey is stem|ext — two files with the same key are duplicates.
func FileDedupKey(f *FileEntry) string {
st := FileEntryStem(f)
ext := FileEntryExt(f)
if st == "" {
return ""
}
if ext == "" {
return st
}
return st + "|" + strings.TrimPrefix(ext, ".")
}
// FindFilesByDedupKey returns folder files matching stem and extension.
func FindFilesByDedupKey(files []*FileEntry, stem, ext string) []*FileEntry {
key := dedupKeyFromParts(stem, ext)
if key == "" {
return nil
}
var out []*FileEntry
for _, f := range files {
if FileDedupKey(f) == key {
out = append(out, f)
}
}
return out
}
func dedupKeyFromParts(stem, ext string) string {
stem = strings.TrimSpace(stem)
if stem == "" {
return ""
}
ext = strings.ToLower(strings.TrimSpace(ext))
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
if ext == "" {
return stem
}
return stem + "|" + strings.TrimPrefix(ext, ".")
}
// UploadExtFromLocal returns the lowercase extension from a local path.
func UploadExtFromLocal(localPath string) string {
ext := filepath.Ext(localPath)
if ext == "" {
return ""
}
return strings.ToLower(ext)
}
// IsTrashFolderTitle reports staging/trash folders (e.g. _trash-md).
func IsTrashFolderTitle(title string) bool {
t := strings.ToLower(strings.TrimSpace(title))
return strings.HasPrefix(t, "_") || strings.Contains(t, "trash")
}
// ProjectFolderFile ties a file to its project Documents subfolder.
type ProjectFolderFile struct {
FolderID string
FolderTitle string
File *FileEntry
}
// DedupGroup is one duplicate set: keep the newest (or non-trash) file.
type DedupGroup struct {
Key string
FolderID string
FolderTitle string
Keep *FileEntry
Remove []*FileEntry
}
// DedupOptions controls project-wide duplicate scans.
type DedupOptions struct {
CrossFolder bool
}
// FindProjectDuplicates scans project folders for duplicate files.
func FindProjectDuplicates(folders []*FolderEntry, filesByFolder map[string][]*FileEntry, opts DedupOptions) []DedupGroup {
var indexed []ProjectFolderFile
for _, folder := range folders {
if folder == nil || folder.ID == nil {
continue
}
fid := folder.ID.String()
title := ""
if folder.Title != nil {
title = *folder.Title
}
for _, f := range filesByFolder[fid] {
if f == nil {
continue
}
indexed = append(indexed, ProjectFolderFile{
FolderID: fid, FolderTitle: title, File: f,
})
}
}
if opts.CrossFolder {
return findCrossFolderDuplicates(indexed)
}
return findWithinFolderDuplicates(indexed)
}
func findWithinFolderDuplicates(indexed []ProjectFolderFile) []DedupGroup {
byFolder := map[string][]ProjectFolderFile{}
for _, it := range indexed {
byFolder[it.FolderID] = append(byFolder[it.FolderID], it)
}
var out []DedupGroup
for fid, items := range byFolder {
title := ""
if len(items) > 0 {
title = items[0].FolderTitle
}
byKey := map[string][]*FileEntry{}
for _, it := range items {
k := FileDedupKey(it.File)
byKey[k] = append(byKey[k], it.File)
}
for k, group := range byKey {
if len(group) < 2 {
continue
}
keep, remove := pickDuplicateKeeper(group, false)
if keep == nil || len(remove) == 0 {
continue
}
out = append(out, DedupGroup{
Key: k, FolderID: fid, FolderTitle: title, Keep: keep, Remove: remove,
})
}
}
sortDedupGroups(out)
return out
}
func findCrossFolderDuplicates(indexed []ProjectFolderFile) []DedupGroup {
byKey := map[string][]ProjectFolderFile{}
for _, it := range indexed {
k := FileDedupKey(it.File)
byKey[k] = append(byKey[k], it)
}
var out []DedupGroup
for k, items := range byKey {
if len(items) < 2 {
continue
}
files := make([]*FileEntry, len(items))
folders := make([]string, len(items))
folderTitles := make([]string, len(items))
for i, it := range items {
files[i] = it.File
folders[i] = it.FolderID
folderTitles[i] = it.FolderTitle
}
keep, remove := pickDuplicateKeeperWithFolders(files, folders, folderTitles)
if keep == nil || len(remove) == 0 {
continue
}
fid, ftitle := "", ""
for _, it := range items {
if it.File == keep {
fid, ftitle = it.FolderID, it.FolderTitle
break
}
}
out = append(out, DedupGroup{
Key: k, FolderID: fid, FolderTitle: ftitle, Keep: keep, Remove: remove,
})
}
sortDedupGroups(out)
return out
}
func pickDuplicateKeeper(files []*FileEntry, _ bool) (*FileEntry, []*FileEntry) {
return pickDuplicateKeeperWithFolders(files, nil, nil)
}
func pickDuplicateKeeperWithFolders(files []*FileEntry, folderIDs, folderTitles []string) (*FileEntry, []*FileEntry) {
if len(files) == 0 {
return nil, nil
}
type ranked struct {
file *FileEntry
trash bool
}
rankedFiles := make([]ranked, len(files))
for i, f := range files {
trash := false
if folderTitles != nil && i < len(folderTitles) {
trash = IsTrashFolderTitle(folderTitles[i])
}
rankedFiles[i] = ranked{file: f, trash: trash}
}
sort.SliceStable(rankedFiles, func(i, j int) bool {
ri, rj := rankedFiles[i], rankedFiles[j]
if ri.trash != rj.trash {
return !ri.trash // non-trash first
}
ti, tj := rankedFiles[i].file.Updated, rankedFiles[j].file.Updated
if ti == nil {
return false
}
if tj == nil {
return true
}
return ti.After(*tj) // newest first
})
keep := rankedFiles[0].file
var remove []*FileEntry
for _, r := range rankedFiles[1:] {
remove = append(remove, r.file)
}
return keep, remove
}
func sortDedupGroups(groups []DedupGroup) {
sort.Slice(groups, func(i, j int) bool {
if groups[i].FolderTitle != groups[j].FolderTitle {
return groups[i].FolderTitle < groups[j].FolderTitle
}
return groups[i].Key < groups[j].Key
})
}
// ApplyDedupGroups deletes Remove files from each group.
func (c *Client) ApplyDedupGroups(ctx context.Context, groups []DedupGroup) ([]int, error) {
seen := map[int]struct{}{}
var ids []int
for _, g := range groups {
for _, f := range g.Remove {
n := int(FileEntryNumericID(f))
if n == 0 {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
ids = append(ids, n)
}
}
if len(ids) == 0 {
return nil, nil
}
if err := c.DeleteFiles(ctx, ids); err != nil {
return ids, err
}
return ids, nil
}
// DeleteFilesByDedupKey removes all files in folderID matching stem+ext.
func (c *Client) DeleteFilesByDedupKey(ctx context.Context, folderID, stem, ext string) ([]int, error) {
files, err := c.FolderFiles(ctx, folderID)
if err != nil {
return nil, err
}
matches := FindFilesByDedupKey(files, stem, ext)
if len(matches) == 0 {
return nil, nil
}
ids := make([]int, 0, len(matches))
for _, f := range matches {
n := int(FileEntryNumericID(f))
if n != 0 {
ids = append(ids, n)
}
}
if len(ids) == 0 {
return nil, nil
}
if err := c.DeleteFiles(ctx, ids); err != nil {
return nil, err
}
return ids, nil
}
// DedupeProject scans project folders and optionally deletes duplicates.
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
pf, err := c.GetProjectFiles(ctx, projectID)
if err != nil {
return nil, nil, err
}
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders))
for _, folder := range pf.Folders {
if folder == nil || folder.ID == nil {
continue
}
fid := folder.ID.String()
files, err := c.FolderFiles(ctx, fid)
if err != nil {
return nil, nil, err
}
filesByFolder[fid] = files
}
groups := FindProjectDuplicates(pf.Folders, filesByFolder, opts)
if !apply || len(groups) == 0 {
return groups, nil, nil
}
deleted, err := c.ApplyDedupGroups(ctx, groups)
return groups, deleted, err
}
+81
View File
@@ -0,0 +1,81 @@
package onlyoffice
import (
"encoding/json"
"testing"
"time"
)
func TestFileDedupKey(t *testing.T) {
title := "OO-HONDA-7-INDEX.docx"
exst := ".docx"
f := &FileEntry{Title: &title, FileExst: &exst}
if got := FileDedupKey(f); got != "OO-HONDA-7-INDEX|docx" {
t.Fatalf("got %q", got)
}
}
func TestFindFilesByDedupKey(t *testing.T) {
a := &FileEntry{Title: strPtr("foo.docx"), FileExst: strPtr(".docx")}
b := &FileEntry{Title: strPtr("foo.md"), FileExst: strPtr(".md")}
files := []*FileEntry{a, b}
got := FindFilesByDedupKey(files, "foo", ".docx")
if len(got) != 1 || got[0] != a {
t.Fatalf("got %+v", got)
}
}
func TestFindWithinFolderDuplicates(t *testing.T) {
t1 := time.Date(2026, 8, 27, 16, 0, 0, 0, time.UTC)
t2 := t1.Add(time.Hour)
old := &FileEntry{ID: jsonNum("1"), Title: strPtr("idx.docx"), FileExst: strPtr(".docx"), Updated: &t1}
new := &FileEntry{ID: jsonNum("2"), Title: strPtr("idx.docx"), FileExst: strPtr(".docx"), Updated: &t2}
indexed := []ProjectFolderFile{
{FolderID: "490", FolderTitle: "00-Index", File: old},
{FolderID: "490", FolderTitle: "00-Index", File: new},
}
groups := findWithinFolderDuplicates(indexed)
if len(groups) != 1 {
t.Fatalf("groups=%d", len(groups))
}
if FileEntryNumericID(groups[0].Keep) != 2 {
t.Fatalf("keep id=%d", FileEntryNumericID(groups[0].Keep))
}
if len(groups[0].Remove) != 1 || FileEntryNumericID(groups[0].Remove[0]) != 1 {
t.Fatalf("remove=%v", groups[0].Remove)
}
}
func TestCrossFolderPrefersNonTrash(t *testing.T) {
t1 := time.Date(2026, 8, 27, 18, 0, 0, 0, time.UTC)
t2 := t1.Add(-time.Hour)
trash := &FileEntry{ID: jsonNum("10"), Title: strPtr("INDEX.md"), FileExst: strPtr(".md"), Updated: &t1}
good := &FileEntry{ID: jsonNum("20"), Title: strPtr("INDEX.md"), FileExst: strPtr(".md"), Updated: &t2}
indexed := []ProjectFolderFile{
{FolderID: "493", FolderTitle: "_trash-md", File: trash},
{FolderID: "492", FolderTitle: "OCR", File: good},
}
groups := findCrossFolderDuplicates(indexed)
if len(groups) != 1 {
t.Fatalf("groups=%d", len(groups))
}
if FileEntryNumericID(groups[0].Keep) != 20 {
t.Fatalf("keep id=%d", FileEntryNumericID(groups[0].Keep))
}
}
func TestIsTrashFolderTitle(t *testing.T) {
if !IsTrashFolderTitle("_trash-md") {
t.Fatal("expected trash")
}
if IsTrashFolderTitle("00-Index") {
t.Fatal("expected not trash")
}
}
func strPtr(s string) *string { return &s }
func jsonNum(s string) *json.Number {
n := json.Number(s)
return &n
}
+129
View File
@@ -0,0 +1,129 @@
package onlyoffice
import (
"context"
"encoding/json"
"path/filepath"
"strings"
)
// FileEntryStem returns the logical basename without duplicated extensions.
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
func FileEntryStem(f *FileEntry) string {
if f == nil || f.Title == nil {
return ""
}
exst := ""
if f.FileExst != nil {
exst = *f.FileExst
}
return NormalizeUploadStem(*f.Title, exst)
}
// NormalizeUploadStem derives a stable stem for matching uploads.
func NormalizeUploadStem(title, exst string) string {
t := strings.TrimSpace(title)
t = strings.TrimSuffix(t, ".")
if exst != "" && strings.HasSuffix(t, exst) {
t = strings.TrimSuffix(t, exst)
}
if ext := filepath.Ext(t); ext != "" {
t = strings.TrimSuffix(t, ext)
}
return strings.TrimSpace(t)
}
// UploadStemFromLocal returns the stem used to match/replace folder files.
func UploadStemFromLocal(localPath string) string {
base := filepath.Base(localPath)
ext := filepath.Ext(base)
if ext != "" {
base = strings.TrimSuffix(base, ext)
}
return base
}
// FolderFiles returns file entries in a Documents folder.
func (c *Client) FolderFiles(ctx context.Context, folderID string) ([]*FileEntry, error) {
raw, err := c.ListFolder(ctx, folderID)
if err != nil {
return nil, err
}
return ParseFolderFileEntries(raw), nil
}
// ParseFolderFileEntries extracts []*FileEntry from ListFolder JSON.
func ParseFolderFileEntries(raw map[string]any) []*FileEntry {
items, _ := raw["files"].([]any)
out := make([]*FileEntry, 0, len(items))
for _, it := range items {
m, ok := it.(map[string]any)
if !ok {
continue
}
b, err := json.Marshal(m)
if err != nil {
continue
}
var f FileEntry
if err := json.Unmarshal(b, &f); err != nil {
continue
}
out = append(out, &f)
}
return out
}
// FindFilesByStem returns folder files whose logical stem matches.
func FindFilesByStem(files []*FileEntry, stem string) []*FileEntry {
stem = strings.TrimSpace(stem)
if stem == "" {
return nil
}
var out []*FileEntry
for _, f := range files {
if FileEntryStem(f) == stem {
out = append(out, f)
}
}
return out
}
// DeleteFilesByStem removes all files in folderID matching stem (any extension).
// Prefer DeleteFilesByDedupKey when the upload extension is known.
func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) ([]int, error) {
files, err := c.FolderFiles(ctx, folderID)
if err != nil {
return nil, err
}
matches := FindFilesByStem(files, stem)
if len(matches) == 0 {
return nil, nil
}
ids := make([]int, 0, len(matches))
for _, f := range matches {
n := int(FileEntryNumericID(f))
if n != 0 {
ids = append(ids, n)
}
}
if len(ids) == 0 {
return nil, nil
}
if err := c.DeleteFiles(ctx, ids); err != nil {
return nil, err
}
return ids, nil
}
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
stem := UploadStemFromLocal(localPath)
ext := UploadExtFromLocal(localPath)
deleted, err := c.DeleteFilesByDedupKey(ctx, folderID, stem, ext)
if err != nil {
return nil, deleted, err
}
ent, err := c.UploadToFolder(ctx, folderID, localPath)
return ent, deleted, err
}
+35
View File
@@ -0,0 +1,35 @@
package onlyoffice
import "testing"
func TestNormalizeUploadStem(t *testing.T) {
tests := []struct {
title, exst, want string
}{
{"OO-HONDA-7-INDEX.docx", ".docx", "OO-HONDA-7-INDEX"},
{"README.txt", ".txt", "README"},
{"car-docs-print.docx", ".docx", "car-docs-print"},
{"plain.", "", "plain"},
{"foo", ".docx", "foo"},
}
for _, tc := range tests {
if got := NormalizeUploadStem(tc.title, tc.exst); got != tc.want {
t.Fatalf("NormalizeUploadStem(%q,%q)=%q want %q", tc.title, tc.exst, got, tc.want)
}
}
}
func TestFileEntryStem(t *testing.T) {
title := "00-INDEX.docx"
exst := ".docx"
f := &FileEntry{Title: &title, FileExst: &exst}
if got := FileEntryStem(f); got != "00-INDEX" {
t.Fatalf("got %q", got)
}
}
func TestUploadStemFromLocal(t *testing.T) {
if got := UploadStemFromLocal("/tmp/OO-HONDA-7-INDEX.docx"); got != "OO-HONDA-7-INDEX" {
t.Fatalf("got %q", got)
}
}
+421
View File
@@ -0,0 +1,421 @@
package onlyoffice
// WebDAV-oriented Files operations. These expose the Documents module through
// value types and cover everything needed to back a filesystem mapping:
// listing (including the virtual @root sections), folder/file CRUD, move/copy,
// and streaming upload/download. They are intentionally small and dependency
// free (only net/http), so callers are not forced to import heavier parts of
// the library.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// DavFolder is a folder row from the Files module.
type DavFolder struct {
ID string
Title string
ParentID string
RootType int // 1=Common, 3=Trash, 5=My, 6=Share, 8=Projects, ...
FilesCount int
FoldersCount int
Access int
Shared bool
Updated string
}
// DavFile is a file row from the Files module.
type DavFile struct {
ID string
Title string
Size int64
Updated string
ViewURL string
}
// DavListing is the contents of one folder.
type DavListing struct {
Current DavFolder
Files []DavFile
Folders []DavFolder
}
// ListDavFolder returns the contents of a folder by id, which may be a
// symbolic root such as "@my". For "@root" use ListDavSections.
func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) {
raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
// @root returns an array with a single blob; a normal folder returns an
// object. Normalize both.
if len(resp) > 0 && resp[0] == '[' {
var arr []*DavListing
if err := json.Unmarshal(resp, &arr); err != nil {
return nil, err
}
if len(arr) == 0 {
return &DavListing{}, nil
}
return arr[0], nil
}
var l DavListing
if err := json.Unmarshal(resp, &l); err != nil {
return nil, err
}
return &l, nil
}
// ListDavSections returns the virtual top-level sections shown by @root
// ("In projects", "My documents", "Shared with me", "Common", "Favorites",
// "Recent", "Trash"). Each is the `current` folder of one @root element.
func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) {
raw, err := c.getJSON(ctx, "/api/2.0/files/@root")
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var arr []struct {
Current DavFolder `json:"current"`
}
if err := json.Unmarshal(resp, &arr); err != nil {
// Tolerate a non-array (single listing) response.
var single DavListing
if err2 := json.Unmarshal(resp, &single); err2 != nil {
return nil, err
}
return []DavFolder{single.Current}, nil
}
sections := make([]DavFolder, 0, len(arr))
for i := range arr {
sections = append(sections, arr[i].Current)
}
return sections, nil
}
// CreateDavFolder creates a folder titled title inside parentID.
func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) {
raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID),
map[string]string{"title": title})
if err != nil {
return nil, err
}
var env struct {
Response *DavFolder `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
if env.Response == nil {
return nil, fmt.Errorf("onlyoffice: empty create-folder response")
}
return env.Response, nil
}
// RenameDavFolder renames a folder.
func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error {
_, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id),
map[string]string{"title": title})
return err
}
// RenameDavFile renames a file (title includes the extension).
func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
_, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id),
map[string]string{"title": title})
return err
}
// MoveDavItems moves the given folders and/or files into destFolderID.
func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{
"folderIds": nums(folderIDs),
"fileIds": nums(fileIDs),
"destFolderId": num(destFolderID),
"resolveType": "Skip",
"holdResult": true,
})
return err
}
// CopyDavItems copies the given folders and/or files into destFolderID.
func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error {
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{
"folderIds": nums(folderIDs),
"fileIds": nums(fileIDs),
"destFolderId": num(destFolderID),
"conflictResolveType": "Skip",
"deleteAfter": true,
})
return err
}
// DeleteDavItems deletes the given folders and/or files.
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
body := map[string]any{"DeleteAfter": true, "Immediately": true}
for _, id := range folderIDs {
if _, err := c.deleteJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), body); err != nil {
return err
}
}
for _, id := range fileIDs {
if _, err := c.deleteJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), body); err != nil {
return err
}
}
return nil
}
// UploadDavFile uploads src (fileName) into folderID, streaming from src.
func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) {
raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src)
if err != nil {
return nil, err
}
var env struct {
Response *DavFile `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
if env.Response == nil {
return nil, fmt.Errorf("onlyoffice: empty upload response")
}
return env.Response, nil
}
// DownloadDavFile streams the file identified by id to w, returning bytes copied.
func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) {
file, err := c.GetFile(ctx, id)
if err != nil {
return 0, err
}
if file.ViewURL == nil || *file.ViewURL == "" {
return 0, fmt.Errorf("onlyoffice: file %s has no viewUrl", id)
}
u := c.resolveAPIURL(*file.ViewURL)
auth, err := c.authHeader()
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", auth)
resp, err := c.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return 0, fmt.Errorf("onlyoffice: download: %d", resp.StatusCode)
}
return io.Copy(w, resp.Body)
}
// --- internal helpers -------------------------------------------------------
// deleteJSON performs an authenticated DELETE with an optional JSON body.
func (c *Client) deleteJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(buf)
}
auth, err := c.authHeader()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// uploadReader uploads a stream to path under the given form field name.
func (c *Client) uploadReader(ctx context.Context, path, fieldName, fileName string, src io.Reader) (json.RawMessage, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile(fieldName, fileName)
if err != nil {
return nil, err
}
if _, err := io.Copy(part, src); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
auth, err := c.authHeader()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
func nums(ids []string) []json.Number {
out := make([]json.Number, 0, len(ids))
for _, id := range ids {
if _, err := strconv.Atoi(id); err == nil {
out = append(out, json.Number(id))
}
}
return out
}
func num(id string) any {
if _, err := strconv.Atoi(id); err == nil {
return json.Number(id)
}
return id
}
// UnmarshalJSON decodes a folder from the portal envelope, including fields
// that the base FolderEntry omits (parentId, rootFolderType, access, ...).
func (f *DavFolder) UnmarshalJSON(b []byte) error {
var raw struct {
ID *json.Number `json:"id"`
Title *string `json:"title"`
ParentID *json.Number `json:"parentId"`
RootType *int `json:"rootFolderType"`
FilesCount *int `json:"filesCount"`
FoldersCount *int `json:"foldersCount"`
Access *int `json:"access"`
Shared *bool `json:"shared"`
Updated *string `json:"updated"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
if raw.ID != nil {
f.ID = raw.ID.String()
}
if raw.Title != nil {
f.Title = *raw.Title
}
if raw.ParentID != nil {
f.ParentID = raw.ParentID.String()
}
if raw.RootType != nil {
f.RootType = *raw.RootType
}
if raw.FilesCount != nil {
f.FilesCount = *raw.FilesCount
}
if raw.FoldersCount != nil {
f.FoldersCount = *raw.FoldersCount
}
if raw.Access != nil {
f.Access = *raw.Access
}
if raw.Shared != nil {
f.Shared = *raw.Shared
}
if raw.Updated != nil {
f.Updated = *raw.Updated
}
return nil
}
// UnmarshalJSON decodes a file row, capturing size and timestamps.
func (f *DavFile) UnmarshalJSON(b []byte) error {
var raw struct {
ID *json.Number `json:"id"`
Title *string `json:"title"`
PureSize *int64 `json:"pureContentLength"`
SizeStr *string `json:"contentLength"`
Updated *string `json:"updated"`
ViewURL *string `json:"viewUrl"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
if raw.ID != nil {
f.ID = raw.ID.String()
}
if raw.Title != nil {
f.Title = *raw.Title
}
if raw.PureSize != nil {
f.Size = *raw.PureSize
} else if raw.SizeStr != nil {
if n, err := strconv.ParseInt(strings.Fields(*raw.SizeStr)[0], 10, 64); err == nil {
f.Size = n
}
}
if raw.Updated != nil {
f.Updated = *raw.Updated
}
if raw.ViewURL != nil {
f.ViewURL = *raw.ViewURL
}
return nil
}
// ModTime parses the folder's updated timestamp.
func (f *DavFolder) ModTime() time.Time {
t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated)
return t
}
// ModTime parses the file's updated timestamp.
func (f *DavFile) ModTime() time.Time {
t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated)
return t
}
+28 -9
View File
@@ -8,38 +8,57 @@ require (
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v0.12.1
github.com/charmbracelet/x/ansi v0.1.4
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002
github.com/eslider/go-xls/v2 v2.1.0
github.com/google/go-querystring v1.2.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-runewidth v0.0.15
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/xuri/excelize/v2 v2.11.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0
)
require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.1.4 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+85 -12
View File
@@ -8,6 +8,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -29,16 +31,30 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI=
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002 h1:LOFxQG4mxvlH7+lffbO2SIn2ThlClJlrHHfs1OqMNXs=
github.com/eslider/go-hocr v0.2.2-0.20260827163626-8ff01582b002/go.mod h1:fIgfH/E1j3rU8du4X4+7mxTD0GPtPQibTzytgitdJWU=
github.com/eslider/go-xls/v2 v2.1.0 h1:HszWKqYQbXxACmAXXWdMsfNl1NDBfGVBnJUPtyUHQ7A=
github.com/eslider/go-xls/v2 v2.1.0/go.mod h1:xgxO6JrfuBr9jGUB+0z5l/yDmFFZ5diGk0ATGihxlMU=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -47,8 +63,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -64,8 +80,16 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -79,21 +103,70 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+73 -1
View File
@@ -113,7 +113,8 @@ func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any,
// unmarshalResponseObject extracts the "response" field from a raw OnlyOffice
// envelope and decodes it into map[string]any. Returns (nil, nil) for a null
// response and (nil, err) when the field is missing or malformed.
// response, an empty array, or scalar payloads. When the API returns a list
// (for example PUT /people/status/...), the first element is returned.
func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
resp, err := responseField(raw, "response")
if err != nil {
@@ -122,11 +123,25 @@ func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
if len(resp) == 0 || string(resp) == "null" {
return nil, nil
}
switch resp[0] {
case '{':
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
case '[':
var list []map[string]any
if err := json.Unmarshal(resp, &list); err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
return list[0], nil
default:
return nil, nil
}
}
// getJSON issues an authenticated GET and returns the raw response body.
@@ -166,6 +181,11 @@ func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (j
return c.formRequest(ctx, http.MethodPut, path, fields)
}
// deleteForm issues an authenticated DELETE with application/x-www-form-urlencoded body.
func (c *Client) deleteForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
return c.formRequest(ctx, http.MethodDelete, path, fields)
}
func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
@@ -229,6 +249,58 @@ func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[
return unmarshalResponseObject(raw)
}
// postJSONObject issues an authenticated POST with JSON body and decodes response.
func (c *Client) postJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
raw, err := c.postJSON(ctx, path, body)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// postJSON issues an authenticated POST with application/json body.
func (c *Client) postJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
return nil, err
}
var rdr io.Reader
switch b := body.(type) {
case nil:
rdr = strings.NewReader("{}")
case []byte:
rdr = bytes.NewReader(b)
case string:
rdr = strings.NewReader(b)
default:
buf, err := json.Marshal(b)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("POST JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// putJSON issues an authenticated PUT with application/json body.
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader()
+50
View File
@@ -0,0 +1,50 @@
package onlyoffice
import (
"encoding/json"
"testing"
)
func TestUnmarshalResponseObjectMap(t *testing.T) {
raw := json.RawMessage(`{"response":{"id":"1","isAdmin":true}}`)
out, err := unmarshalResponseObject(raw)
if err != nil {
t.Fatal(err)
}
if out["id"] != "1" || out["isAdmin"] != true {
t.Fatalf("unexpected map: %#v", out)
}
}
func TestUnmarshalResponseObjectArray(t *testing.T) {
raw := json.RawMessage(`{"response":[{"id":"a"},{"id":"b"}]}`)
out, err := unmarshalResponseObject(raw)
if err != nil {
t.Fatal(err)
}
if out["id"] != "a" {
t.Fatalf("expected first array element, got %#v", out)
}
}
func TestUnmarshalResponseObjectEmptyArray(t *testing.T) {
raw := json.RawMessage(`{"response":[]}`)
out, err := unmarshalResponseObject(raw)
if err != nil {
t.Fatal(err)
}
if out != nil {
t.Fatalf("expected nil for empty array, got %#v", out)
}
}
func TestUnmarshalResponseObjectNull(t *testing.T) {
raw := json.RawMessage(`{"response":null}`)
out, err := unmarshalResponseObject(raw)
if err != nil {
t.Fatal(err)
}
if out != nil {
t.Fatalf("expected nil, got %#v", out)
}
}

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