Compare commits

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

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

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

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

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

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

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

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

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

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

http.go: putJSON for JSON PUT bodies.

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-24 14:55:21 +01:00
85 changed files with 9328 additions and 2314 deletions
+12 -1
View File
@@ -1,4 +1,4 @@
# OnlyOffice Workspace credentials. Used by cmd/oo-cli (the library itself # OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
# does not load .env files — only its CLI does, via godotenv). # does not load .env files — only its CLI does, via godotenv).
# #
# Primary names: # Primary names:
@@ -10,7 +10,18 @@ ONLYOFFICE_PASS=
# ONLYOFFICE_HOST= # ONLYOFFICE_HOST=
# ONLYOFFICE_NAME= # ONLYOFFICE_NAME=
# ONLYOFFICE_PASSWORD= # ONLYOFFICE_PASSWORD=
#
# produktor.io operator aliases (CLI-only):
# OO_URL=
# OO_USER=
# OO_PASS=
# Optional defaults for calendar/project helpers: # Optional defaults for calendar/project helpers:
ONLYOFFICE_CALENDAR_ID=1 ONLYOFFICE_CALENDAR_ID=1
ONLYOFFICE_PROJECT_ID=33 ONLYOFFICE_PROJECT_ID=33
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
# ONLYOFFICE_DOCS_URL=https://docs.example.com
# ONLYOFFICE_DOCS_SECRET=
+28
View File
@@ -0,0 +1,28 @@
name: Release Please
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
issues: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
steps:
- name: Run release-please
uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
+39
View File
@@ -0,0 +1,39 @@
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Git tag to release (e.g. v0.4.0)
required: true
type: string
permissions:
contents: write
jobs:
goreleaser:
name: GoReleaser
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+49
View File
@@ -0,0 +1,49 @@
name: Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
name: Test (Go ${{ matrix.go }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go: ["1.22", "stable"]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
cache: true
- name: Verify dependencies
run: |
go mod verify
go mod download
- name: Check go mod tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum
- name: Vet
run: go vet ./...
- name: Build oo
run: go build -trimpath -buildvcs=false -o /tmp/oo ./cmd/oo
- name: Build office
run: go build -trimpath -buildvcs=false -o /tmp/office ./cmd/office
- name: Run tests
run: go test -race -shuffle=on -count=1 ./...
+2 -1
View File
@@ -5,6 +5,7 @@
go.work go.work
go.work.sum go.work.sum
# Build artifacts (compiled top-level binary only; do NOT match cmd/oo-cli source tree) # Build artifacts (top-level compiled binaries; cmd/oo/ source tree is tracked)
/oo
/oo-cli /oo-cli
/bin/ /bin/
+124
View File
@@ -0,0 +1,124 @@
version: 2
project_name: go-onlyoffice
before:
hooks:
- go mod tidy
- go mod verify
builds:
- id: oo
main: ./cmd/oo
binary: oo
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
- id: office
main: ./cmd/office
binary: office
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
archives:
- id: oo
ids: [oo]
name_template: "oo_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
- id: office
ids: [office]
name_template: "office_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
checksum:
name_template: checksums.txt
algorithm: sha256
snapshot:
version_template: "{{ .Tag }}-next"
changelog:
use: github
sort: asc
groups:
- title: Features
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 0
- title: Bug fixes
regexp: '^.*?fix(\(.+\))??!?:.+$'
order: 1
- title: Performance
regexp: '^.*?perf(\(.+\))??!?:.+$'
order: 2
- title: Others
order: 999
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^style:"
- "^build:"
- "Merge pull request"
- "Merge branch"
release:
draft: false
prerelease: auto
mode: replace
header: |
## Release {{ .Tag }}
Install:
```sh
go install github.com/eslider/go-onlyoffice/cmd/oo@{{ .Tag }}
go install github.com/eslider/go-onlyoffice/cmd/office@{{ .Tag }}
```
Or download prebuilt binaries below.
footer: |
**Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.5.0"
}
+20 -4
View File
@@ -1,17 +1,33 @@
# AGENTS — go-onlyoffice # AGENTS — go-onlyoffice
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo-cli` command. Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo` command.
## Topology ## Topology
- **Library (root package `onlyoffice`)** — `onlyoffice.go`, `httpx.go`, `calendar.go`, `crm.go`, `tasks_extra.go`, `files.go`. Pure stdlib + `google/go-querystring`; no UI, no dotenv. - **Library — flat package `onlyoffice` at repo root.** Split by *domain file*, not by subpackage, so every call site reads `c.XxxYyy()` against a single `*Client`. Files:
- **CLI (`cmd/oo-cli` + `internal/cli`)** — Cobra wrapper that loads `.env` via `godotenv` at startup. CLI-only deps (`spf13/cobra`, `joho/godotenv`) must stay out of the library surface. - `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
- **Applications sync (`internal/applications`)** — README/CRM bridge, consumes the library. - `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`).
- 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()`.
- 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/`.
- **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.
## Rules ## Rules
- Library must never call `godotenv.Load()` — the CLI does that. - Library must never call `godotenv.Load()` — the CLI does that.
- New endpoints go into the library first; CLI commands are thin wrappers. - New endpoints go into the library first; CLI commands are thin wrappers.
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
- 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. - 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`. - Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
+240
View File
@@ -4,8 +4,248 @@ 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 [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). adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
### Features
* **office:** scrollable panes, nav tree drill-down, and item actions ([fe2ee48](https://github.com/eSlider/go-onlyoffice/commit/fe2ee481b9c0f959b798fd40679084a8c4bc4ba2))
## [0.4.0](https://github.com/eSlider/go-onlyoffice/compare/v0.3.2...v0.4.0) (2026-06-24)
### ⚠ BREAKING CHANGES
* **cli:** subject-based command tree (tea-style) + global --output flag
* rename oo-cli → oo, split library by domain, relocate applications
### Features
* **cli:** subject-based command tree (tea-style) + global --output flag ([1e6d22f](https://github.com/eSlider/go-onlyoffice/commit/1e6d22f38dd7a70d4cf1b69ef0b8749b01fded1a))
* **crm:** dedupe duplicates, fix deal titles, and add cleanup CLI ([c77519f](https://github.com/eSlider/go-onlyoffice/commit/c77519fac5ae9dffaad3f1f0ae722d969e62dde9))
* **crm:** merge company slogan variants in dedupe grouping ([3eb649c](https://github.com/eSlider/go-onlyoffice/commit/3eb649c58bf489b221f90734e04f33063f736914))
* **files:** project/task Documents API + oo projects|tasks files ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
* **mails:** add Workspace mail CLI with pagination and parsed from fields ([1cb228e](https://github.com/eSlider/go-onlyoffice/commit/1cb228e5d83ab453660bbace5f936a77f9381fc2))
* **office:** add Workspace TUI with shared bootstrap and test suite ([7358ff5](https://github.com/eSlider/go-onlyoffice/commit/7358ff5c326cdc873cb68b3f96d31155ecba1ee3))
### Code Refactoring
* rename oo-cli → oo, split library by domain, relocate applications ([cff8145](https://github.com/eSlider/go-onlyoffice/commit/cff8145f8c35656a77b7a36338428bf9be0eda0f))
### Documentation
* CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree. ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
## [Unreleased] ## [Unreleased]
### Added — `office` TUI
- **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace:
three-pane layout (module tree, multi-select list, markdown preview).
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
and `office`.
- Integration tests under `cmd/office/fetch/` and `cmd/office/preview/`
(`-tags=integration`) hit the live OnlyOffice API — no fixture maps or
httptest vendor mocks. Unit tests remain for pure UI/formatting helpers only.
### Added — library
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
merge duplicate companies/persons/deals by normalized name; remove duplicate
contact-info rows and opportunity members (including same `displayName` with
different ids); fix malformed deal titles (` @ Company` → `Company`).
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names
like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members).
- **Workspace mail** — [`mails.go`](mails.go): `ListMailAccounts`, `ListMailFolders`,
`ListMailMessages`, `GetMailMessage`, `RemoveMailMessages` against OnlyOffice
Mail addon (`/api/2.0/mail/*`).
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
dedupe smoke tests.
### Added — CLI
- `oo mails accounts|folders|list|get|delete` — OnlyOffice Workspace mail (same `ONLYOFFICE_*` creds).
- `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
### Added — CI/CD
- GitHub Actions: `test.yml` (vet, unit tests, `oo` build), `release-please.yml`
(semver from conventional commits), `release.yml` (GoReleaser binaries for `oo`).
### Changed — CLI
- README: expanded **oo CLI use cases** (CRM cleanup, applications sync, mail, contacts).
- `oo applications sync` — empty position uses company-only deal title; skips
duplicate opportunity members; paginated find-or-create lookups.
- `oo` now loads OnlyOffice credentials only from `.env` in the current
working directory.
- Produktor.io shorthand env names are accepted by the CLI dotenv loader:
`OO_URL` → `ONLYOFFICE_URL`, `OO_USER` → `ONLYOFFICE_USER`, and
`OO_PASS` → `ONLYOFFICE_PASS`. Canonical `ONLYOFFICE_*` values still win
when already set in the process environment.
## [0.6.0] - 2026-04-24
### Added — library
- **Project & task Documents API** in [`files.go`](files.go):
- `FileEntry`, `FolderEntry`, `ProjectFilesResponse` types.
- `GetProjectFiles`, `GetTaskFiles`, `GetFile`.
- `UploadProjectFile` — `POST /api/2.0/files/{folderId}/upload` into the
project's `projectFolder` (resolved via `GetProjectByID` or first folder
from `GetProjectFiles`).
- `AttachFilesToTask` — `POST .../project/task/{id}/files` with form
`files=<id>` (OnlyOffice expects **existing** file ids, not multipart).
- `UploadTaskFile` — uploads via `UploadProjectFile` using the task's
`projectOwner.id`, then attaches.
- `DetachTaskFile` — `DELETE .../files?fileid=`.
- `RenameFile` — `PUT /api/2.0/files/file/{id}.json` with JSON body.
- `DeleteFiles` — `PUT /api/2.0/files/fileops/delete.json` with `fileIds`.
- `DownloadFile` — `GetFile` then `GET` on `viewUrl` with `Authorization`.
- Helpers: `FileEntryNumericID`, `FileEntryTitle`, `SafeLocalFileName`.
- **`putJSON`** on `*Client` in [`http.go`](http.go) for JSON PUT bodies.
### Added — CLI
- `oo projects files list|upload|download|rename|delete` — see
[`cmd/oo/projects_files.go`](cmd/oo/projects_files.go); `list` supports
`--folders`.
- `oo tasks files list|upload|detach` — see [`cmd/oo/tasks_files.go`](cmd/oo/tasks_files.go).
### Added — tests
- [`files_integration_test.go`](files_integration_test.go) — live roundtrip
against OnlyOffice (same credential rules as `client_test.go`).
- [`files_test.go`](files_test.go) + `testdata/*.json` — envelope decode unit
tests (no network).
## [0.5.0] - 2026-04-24
### Changed — CLI **BREAKING**
- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun`
layout is replaced with `oo <subject> <verb>`:
| Old | New |
|---|---|
| `oo cal-list` | `oo calendar list` |
| `oo cal-events` | `oo calendar events` |
| `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` |
| `oo task-list` | `oo tasks list` |
| `oo task-add` | `oo tasks create` |
| `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) |
| `oo subtask-add` | `oo tasks subtask add` |
| `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) |
| `oo crm-add-contact --company …` | `oo companies create --name …` |
| `oo crm-add-contact --person-first …` | `oo persons create --first …` |
| `oo crm-deals` | `oo opportunities list` |
| `oo crm-deals --stages` | `oo opportunities stages` |
| `oo crm-add-deal` | `oo opportunities create` |
| `oo crm-cases` | `oo cases list` |
| `oo applications-sync` | `oo applications sync` |
- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`,
`oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks
{list,create,delete,categories}`, `oo cases {create,delete,member-add}`,
`oo contacts {get,info-add}`.
- **Global `--output/-o` flag**: all list-like commands now support
`--output table` (default; tabwriter-aligned, truncated to 80 chars
per cell) and `--output json`. Nested `bidCurrency` flattened to its
`abbreviation` in the table view.
- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`,
`oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`,
`oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that
removes things.
### Added
- `cmd/oo/common.go` — shared `printTable(headers, rows)` and
`printObject(v)` helpers that dispatch on the `--output` flag.
- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami`
via the library's `GetUsers` + `SelfUserID`.
- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`,
`UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`.
- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from
project `oo tasks`).
- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared
list/filter implementation.
### Changed — library (minor)
- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())`
so CLI aborts propagate to the auth request.
## [0.4.0] - 2026-04-24
### Changed — project structure
- **Library files reorganised by domain** (mechanical split; zero API surface
change). The former monolithic `onlyoffice.go` (687 LOC) is now split into:
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`,
`Permissions`, `requestBodyReader`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`,
`Auth`, `ensureToken`, `authHeader`, `tokenValid`.
- `http.go` — transport helpers and DRY response decoders
(`ResponseArray`, `ResponseObject`, `postFormObject`, `putFormObject`,
`deleteObject`, `unmarshalResponseObject`).
- `projects.go` — `Project`, `Projects`, `Milestone`, `ProjectOwner` +
`GetProjects` / `CreateProject` / `UpdateProject` / `DeleteProject` /
`GetProjectByID` / `GetProjectMilestones`.
- `tasks.go` — `Task`, `ProjectTaskStatus`, `TaskPriority`,
`ProjectGetTasksRequest` et al. **plus** the form-encoded helpers
formerly in `tasks_extra.go` (`ListTasks`, `AddTask`, `AddSubtask`,
`UpdateTaskStatus`, `DeleteTask`, `GetTaskByID`).
- `users.go` — `User`, `Contact`, `Group`, `GetUsers`, `SelfUserID`.
- `calendar.go`, `crm.go`, `files.go` — unchanged in scope, refactored
through the new DRY helpers.
- `httpx.go` → **renamed** `http.go`.
- `onlyoffice.go` and `tasks_extra.go` — **deleted** (content redistributed).
### Changed — CLI **BREAKING**
- **Binary renamed `oo-cli` → `oo`.** Install with
`go install github.com/eslider/go-onlyoffice/cmd/oo@latest`.
- **Package path `cmd/oo-cli` → `cmd/oo`.** The old path is removed.
- **`internal/cli` is gone.** Cobra commands now live directly under
`cmd/oo/` as `package main`, split by domain: `calendar.go`, `crm.go`,
`tasks.go`, `apps.go`, `common.go`. Rationale: cobra wiring is a CLI-only
concern and does not belong inside a `pkg-level internal/`.
- **`internal/applications` → `cmd/oo/applications/`.** This is a
CV-specific CRM workflow — not a general OnlyOffice feature — and is only
consumed by the `oo` CLI. Keeping it under `cmd/oo/` prevents accidental
external adoption and makes the coupling explicit.
- **`examples/applications/` removed.** It imported an internal package,
which was a policy smell. Remaining examples (`basic`, `calendar`, `crm`,
`subtasks`) use only the exported library surface.
### Added
- `(*Client).ResponseObject` — GET-and-decode-object counterpart to the
existing `ResponseArray`.
- `(*Client).postFormObject` / `putFormObject` / `deleteObject` — eliminate
the ~15 identical "form request → `responseField` → `json.Unmarshal`"
blocks previously duplicated across `crm.go` / `tasks_extra.go` /
`calendar.go` / `files.go`.
### Migration
External library consumers: **no changes required**. The module path
(`github.com/eslider/go-onlyoffice`), the `onlyoffice` package name, and
every exported symbol are unchanged.
CLI users: replace `oo-cli` with `oo` in scripts and CI. The command set
and flags are identical.
## [0.3.2] - 2026-04-24 ## [0.3.2] - 2026-04-24
### Fixed ### Fixed
+328 -12
View File
@@ -3,6 +3,7 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-onlyoffice.svg)](https://pkg.go.dev/github.com/eslider/go-onlyoffice) [![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-onlyoffice.svg)](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8.svg)](https://go.dev) [![Go](https://img.shields.io/badge/Go-1.22+-00ADD8.svg)](https://go.dev)
[![Tests](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml/badge.svg)](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml)
[![Latest Release](https://img.shields.io/github/v/tag/eSlider/go-onlyoffice?sort=semver&label=release)](https://github.com/eSlider/go-onlyoffice/releases) [![Latest Release](https://img.shields.io/github/v/tag/eSlider/go-onlyoffice?sort=semver&label=release)](https://github.com/eSlider/go-onlyoffice/releases)
[![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-onlyoffice?style=social)](https://github.com/eSlider/go-onlyoffice/stargazers) [![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-onlyoffice?style=social)](https://github.com/eSlider/go-onlyoffice/stargazers)
@@ -535,21 +536,319 @@ company, _ := client.FindCompany(ctx, "ACME")
client.AddSubtask(ctx, "4242", "Prepare CV") client.AddSubtask(ctx, "4242", "Prepare CV")
``` ```
### oo-cli (bundled command) ### oo (bundled CLI)
A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo-cli`: A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the
library lives under [`cmd/oo`](cmd/oo/). The command tree is **subject-based**,
mirroring the [`tea`](https://gitea.com/gitea/tea) CLI:
```bash ```bash
go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest go install github.com/eslider/go-onlyoffice/cmd/oo@latest
oo-cli cal-events
oo-cli task-list --all --verbose # Global: every list-style command takes -o table|json (default: table)
oo-cli subtask-add 4242 "Prepare CV" oo whoami
oo-cli applications-sync --path ./applications/2026 --apply oo users list
oo calendar events --start 2026-04-24 --end 2026-05-01
oo projects list
oo projects get 33
oo tasks list --all --verbose
oo tasks subtask add 4242 "Prepare CV"
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
``` ```
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library ### office (TUI)
itself never loads dotfiles). See [`cmd/oo-cli/README.md`](cmd/oo-cli/) for
the command reference. Terminal UI for browsing OnlyOffice Workspace — module tree (left), selectable
lists (center), and markdown preview (right). Uses the same `.env` credentials
as `oo`. Lives under [`cmd/office`](cmd/office/).
```bash
go install github.com/eslider/go-onlyoffice/cmd/office@latest
office
```
| Key | Action |
|---|---|
| `Tab` | Next pane (menu → list → preview) |
| `Shift+Tab` | Previous pane |
| `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
| `Enter` / `→` | Expand tree branch or open leaf list |
| `Space` | Toggle multi-select on list row |
| `Enter` / `a` | Action menu (view, delete, download, …) |
| `r` | Refresh current list |
| `q` | Quit |
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
```bash
ONLYOFFICE_DOCS_URL=https://docs.example.com
ONLYOFFICE_DOCS_SECRET=… # when JWT signing is enabled
```
Spreadsheet files: inline CSV/JSON preview in the right pane; install
[`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen
xlsx/csv viewing (`v` on a file row — coming in next iteration).
Integration tests for list loaders and preview (live API only — no mocks):
```bash
go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
```
**Project / task documents (`oo`):**
```bash
# Project Documents (files module)
oo projects files list 33
oo projects files upload 33 ./notes.md
oo projects files download 12345 --to ./copy.md
oo projects files rename 12345 notes-v2.md
oo projects files delete 12345
oo tasks files list 208
oo tasks files upload 208 ./cv.pdf
oo tasks files detach 208 12345
```
| Subject | Verbs |
|---|---|
| `calendar` | `list`, `events`, `add`, `delete` |
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
| `users` | `list`, `self` (alias: `oo whoami`) |
| `contacts` | `list`, `get`, `delete`, `info-add` |
| `persons` | `list` (filtered), `create`, `delete` |
| `companies` | `list` (filtered), `create`, `delete` |
| `contacts` | `list`, `get`, `delete`, `info-add`, `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` |
| `crm` | `cleanup` |
| `mails` | `accounts`, `folders`, `list`, `get`, `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`.
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.
## oo CLI use cases
The `oo` binary is the day-to-day operator interface. It loads credentials from
`.env` in the **current working directory** (copy from [`.env.example`](.env.example)):
```bash
cp .env.example .env
# ONLYOFFICE_URL=https://office.example.com
# ONLYOFFICE_USER=you@example.com
# ONLYOFFICE_PASS=…
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
oo whoami
```
Every list command accepts `-o table` (default) or `-o json` for scripting.
### CRM cleanup after imports or sync drift
**Problem:** Duplicate companies (`Acme` / `ACME GmbH`), persons created twice,
the same email on a contact three times, deals titled ` @ contoso`, or the same
HR contact linked to a deal twice.
**One-shot fix** — runs every dedupe pass in order:
```bash
oo crm cleanup -o json
```
Steps inside `crm cleanup`:
| Step | What it does |
|------|----------------|
| `companies` | Merge companies with the same normalized name (slogan variants like `Affirm` / `Affirm — Fraud Engineering` count as one) |
| `persons` | Merge duplicate persons globally (same first+last) |
| `company-persons` | Merge duplicate persons under each company |
| `contact-info` | Remove duplicate email/phone/website rows |
| `opportunity-members` | Drop duplicate contacts on the same deal |
| `opportunities` | Merge duplicate deals by title |
| `fix-titles` | Repair malformed titles (` @ Company` → `Company`) |
**Targeted passes** when you only want one kind of fix:
```bash
# Duplicate company records
oo companies dedupe
# Same person entered twice under one employer
oo companies dedupe-persons
# Global person duplicates (same name, different ids)
oo persons dedupe
# Repeated email/phone rows on contacts
oo contacts dedupe-info
# Two deals with the same title
oo opportunities dedupe
# Same contact attached twice to one deal (common after applications sync)
oo opportunities dedupe-members
# Titles like " @ contoso" or extra whitespace
oo opportunities fix-titles
```
**Deal grouping flag** — when the same role at the same company created
separate deals (`Engineer @ Acme` vs `Engineer`):
```bash
oo opportunities dedupe --ignore-company-suffix
oo crm cleanup --ignore-company-suffix
```
**Inspect before/after:**
```bash
oo opportunities list --count 200 | grep -i contoso
oo contacts get 857 -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`)
**Problem:** Mail lives in OnlyOffice Mail (`/addons/mail/#inbox`), bound to your
portal account — not a separate archive service. You want to list, read, or
remove messages from the shell.
Uses the same `ONLYOFFICE_*` credentials as every other `oo` command.
```bash
# Which mailbox is linked?
oo mails accounts
# Folder counters (inbox unread, trash size, …)
oo mails folders
# Latest inbox messages (API returns 25 per page; --limit paginates automatically)
oo mails list --folder inbox --limit 50
# Page through older mail
oo mails list --folder inbox --limit 100 --offset 100
# Other folders
oo mails list --folder sent --limit 20
oo mails list --folder spam --limit 100
# Read full message (subject, htmlBody, attachments metadata)
oo mails get 5664 -o json | jq '{subject, from, to, date}'
# Remove one or more messages (server moves to trash or deletes per Mail rules)
oo mails delete 5664
oo mails delete 5664 5663 5661
```
**Table output** splits the `from` header into `fromName` and `fromAddress`
(e.g. `Bitfinex` + `no-reply@bitfinex.com`). **JSON output** returns the raw
API payload.
**Scripting example** — export today's inbox subjects:
```bash
oo mails list --folder inbox --limit 200 -o json \
| jq -r '.[] | "\(.id)\t\(.subject)"'
```
### Contacts, companies, and deals (everyday CRM)
```bash
# Search companies
oo companies list --search acme
# Create company + person with primary email
oo companies create --name "Acme GmbH" --website https://acme.com
oo persons create --first Jane --last Doe --email jane@acme.com --company-id 42
# Attach email or LinkedIn to existing contact
oo contacts info-add 42 --type Email --value jane@acme.com --primary
# Pipeline overview
oo opportunities list --count 100
oo opportunities stages
oo opportunities get 231 -o json
```
### Calendar and project ops
```bash
# Next week's events
oo calendar events --start 2026-06-24 --end 2026-07-01
# Schedule interview block
oo calendar add "Technical interview" 2026-06-26T10:00:00Z 2026-06-26T11:00:00Z
# Attach CV to a hiring task
oo tasks files upload 208 ./cv.pdf
oo projects files list 33
```
### Suggested maintenance cadence
| When | Command |
|------|---------|
| After `applications sync --apply` | `oo crm cleanup` |
| After bulk CSV import into CRM | `oo crm cleanup` |
| Weekly inbox triage | `oo mails list --folder inbox --limit 100` |
| Before exec reporting | `oo opportunities list` + `oo projects list` |
## Environment Variables ## Environment Variables
@@ -560,6 +859,23 @@ the command reference.
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password | | `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) | | `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) | | `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` |
Mail, CRM cleanup, and applications sync are documented in [oo CLI use cases](#oo-cli-use-cases) above.
### CI / releases
GitHub Actions (pattern from [`eSlider/go-config`](https://github.com/eSlider/go-config)):
| Workflow | Trigger | Purpose |
|---|---|---|
| `test.yml` | push / PR | `go vet`, unit tests, build `oo` + `office` |
| `release-please.yml` | push to `main` | semver PR from conventional commits |
| `release.yml` | tag `v*` | GoReleaser cross-platform `oo` + `office` binaries |
Repo setting required once: **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**.
Merge the release-please PR to tag a version; GoReleaser publishes assets to [GitHub Releases](https://github.com/eSlider/go-onlyoffice/releases).
## Examples ## Examples
@@ -569,8 +885,8 @@ the command reference.
| [calendar](examples/calendar/) | List calendars and events, create a new event | | [calendar](examples/calendar/) | List calendars and events, create a new event |
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note | | [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks | | [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
| [applications](examples/applications/) | Job application folder tree → OnlyOffice CRM (library-level usage of the internal applications package) | | [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules |
| [`cmd/oo-cli`](cmd/oo-cli/) | Full-featured CLI using all modules | | [`cmd/office`](cmd/office/) | Terminal UI — browse Workspace with markdown preview |
## Related Libraries ## Related Libraries
+121
View File
@@ -0,0 +1,121 @@
package onlyoffice
// Authentication primitives: token lifecycle, eager / context-aware auth, and
// token invalidation. Split out of http.go so auth concerns live together.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Auth authenticates using the given credentials and returns a fresh token.
// Most callers should not call this directly — use Authenticate /
// AuthenticateContext, or let Query fetch a token lazily. Kept exported for
// backwards compatibility.
func (c *Client) Auth(creds *Credentials) (*Token, error) {
t := &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: http.MethodPost,
Body: creds,
NoAuth: true,
}, &struct {
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token
// is fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron,
// watchers) because it guarantees that a stalled auth call will not block
// the caller past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.tokenValid() {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// tokenValid reports whether the cached token is present and not expired.
func (c *Client) tokenValid() bool {
return c.token != nil && !time.Time(c.token.Expires).Before(time.Now())
}
// ensureToken refreshes the authentication token when missing or expired.
// Mirrors the logic inline in Query() but is safe to call from helpers that
// bypass the typed Request abstraction.
func (c *Client) ensureToken() error {
if c.tokenValid() {
return nil
}
tok, err := c.Auth(c.credentials)
if err != nil {
return err
}
c.token = tok
return nil
}
// authHeader returns the value for the Authorization header, ensuring a token.
func (c *Client) authHeader() (string, error) {
if err := c.ensureToken(); err != nil {
return "", err
}
return c.token.Value, nil
}
+6 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"strconv"
) )
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD). // ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
@@ -29,6 +30,9 @@ func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[strin
// AddEvent creates a simple one-shot calendar event. When calendarID is empty // AddEvent creates a simple one-shot calendar event. When calendarID is empty
// the configured default (SetDefaults or GetEnvironmentDefaults) is used. // the configured default (SetDefaults or GetEnvironmentDefaults) is used.
// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601). // Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601).
//
// The underlying API returns an array wrapper — this helper unwraps the first
// element for convenience.
func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) { func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) {
if calendarID == "" { if calendarID == "" {
calendarID = c.defaults.CalendarID calendarID = c.defaults.CalendarID
@@ -43,7 +47,7 @@ func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, de
fields.Set("endDate", end) fields.Set("endDate", end)
fields.Set("repeatType", "") fields.Set("repeatType", "")
fields.Set("alertType", "0") fields.Set("alertType", "0")
fields.Set("isAllDayLong", fmt.Sprintf("%t", allDay)) fields.Set("isAllDayLong", strconv.FormatBool(allDay))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields) raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -64,15 +68,5 @@ func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, de
// DeleteEvent removes a single calendar event by its event ID. // DeleteEvent removes a single calendar event by its event ID.
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) { func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
+108
View File
@@ -0,0 +1,108 @@
// Package onlyoffice provides a Go client for the OnlyOffice Workspace
// (formerly ONLYOFFICE) REST API. It is organised as a flat single package
// intentionally: a single *Client exposes Projects, Tasks, Calendar, CRM and
// Files operations as receiver methods. Domain split is by file, not by
// subpackage, to keep call sites uniform (c.ListContacts, c.GetTasks,
// c.AddEvent, c.UploadOpportunityFile all live on the same handle).
//
// The CLI binary `oo` (see cmd/oo) is a thin cobra wrapper on top of this
// library and mirrors the Python cv/bin/office reference tooling.
package onlyoffice
import (
"net/http"
"os"
"strings"
)
// Client of OnlyOffice API uses credentials to get a token and query the API
// by every request.
//
// Construct with NewClient; optionally set fallbacks via SetDefaults. The
// zero value is NOT usable — credentials are required. A single client is
// safe for sequential use from one goroutine; for concurrent use, callers
// should wrap with their own synchronization or create one client per
// goroutine.
type Client struct {
client *http.Client
credentials *Credentials
token *Token
defaults Defaults // optional fallbacks for calendar/project IDs
selfID string // cached /api/2.0/people/@self id
noteCatID int // cached CRM history category id for "note"
}
// NewClient returns a new Client backed by http.DefaultClient.
func NewClient(c Credentials) *Client {
return &Client{
client: http.DefaultClient,
credentials: &c,
}
}
// Credentials of OnlyOffice User. The Url field is NOT sent with the auth
// payload — it only determines the host.
type Credentials struct {
Url string `json:"-"`
User string `json:"userName"`
Password string `json:"password"`
}
// Defaults holds optional fallbacks used by package-level helpers when callers
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults
// or read from env via GetEnvironmentDefaults.
type Defaults struct {
CalendarID string
ProjectID string
}
// SetDefaults configures optional identifiers used as fallbacks by methods
// such as AddEvent (when calendarID == "") or AddTask (when projectID == "").
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
//
// Primary variables (documented):
// - ONLYOFFICE_URL
// - ONLYOFFICE_USER
// - ONLYOFFICE_PASS
//
// Additional aliases accepted for interoperability with sibling tools:
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
func GetEnvironmentCredentials() Credentials {
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
url = strings.TrimRight(url, "/")
return Credentials{
Url: url,
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
}
}
// GetEnvironmentDefaults reads optional library defaults from environment:
//
// - ONLYOFFICE_CALENDAR_ID (default: "1")
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
func GetEnvironmentDefaults() Defaults {
return Defaults{
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
ProjectID: firstNonEmpty(
os.Getenv("ONLYOFFICE_PROJECT_ID"),
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
"33",
),
}
}
// firstNonEmpty returns the first non-empty trimmed value, or "" if none.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
+50
View File
@@ -0,0 +1,50 @@
// Package bootstrap loads CLI environment and constructs an authenticated
// OnlyOffice client. Shared by cmd/oo and cmd/office; the library never
// loads dotfiles.
package bootstrap
import (
"context"
"fmt"
"os"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
)
// LoadEnv reads .env from the current working directory and applies OO_* aliases.
func LoadEnv() {
_ = godotenv.Load(".env")
applyEnvAliases()
}
// NewClient loads env, validates credentials, and authenticates against OnlyOffice.
func NewClient(ctx context.Context) (*onlyoffice.Client, error) {
LoadEnv()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.AuthenticateContext(ctx); err != nil {
return nil, err
}
return c, nil
}
func applyEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
+95
View File
@@ -0,0 +1,95 @@
package bootstrap_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
)
func TestLoadEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.example.com\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.example.com" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestLoadEnvDoesNotOverrideCanonicalWithAlias(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestNewClientReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
_, err = bootstrap.NewClient(context.Background())
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+108
View File
@@ -0,0 +1,108 @@
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func skipWithoutLiveAPI(t *testing.T) {
t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
if os.Getenv("ONLYOFFICE_USER") == "" && os.Getenv("ONLYOFFICE_NAME") == "" {
t.Skip("ONLYOFFICE_USER not set")
}
if os.Getenv("ONLYOFFICE_PASS") == "" && os.Getenv("ONLYOFFICE_PASSWORD") == "" {
t.Skip("ONLYOFFICE_PASS not set")
}
}
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
t.Helper()
skipWithoutLiveAPI(t)
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
return &fetch.Loader{Client: client}, context.Background()
}
func TestIntegrationListAllSubjects(t *testing.T) {
loader, ctx := liveLoader(t)
cases := []model.ListSpec{
{Subject: model.SubjectProjects},
{Subject: model.SubjectTasks},
{Subject: model.SubjectCalendars},
{Subject: model.SubjectEvents},
{Subject: model.SubjectContacts},
{Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies},
{Subject: model.SubjectOpportunities},
{Subject: model.SubjectCases},
{Subject: model.SubjectCRMTasks},
{Subject: model.SubjectMailInbox},
{Subject: model.SubjectUsers},
}
for _, spec := range cases {
t.Run(string(spec.Subject), func(t *testing.T) {
items, err := loader.List(ctx, spec)
if err != nil {
t.Fatalf("List(%s): %v", spec.Subject, err)
}
t.Logf("%s: %d items", spec.Subject, len(items))
for i, it := range items {
if it.ID == "" {
t.Errorf("item[%d] missing ID", i)
}
if it.Title == "" {
t.Errorf("item[%d] missing Title", i)
}
}
})
}
}
func TestIntegrationListProjectsMapsRealFields(t *testing.T) {
loader, ctx := liveLoader(t)
spec := model.ListSpec{Subject: model.SubjectProjects}
items, err := loader.List(ctx, spec)
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects on instance")
}
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("Detail: %v", err)
}
if detail == nil {
t.Fatal("nil detail")
}
}
func TestIntegrationMailInboxDetail(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("inbox empty")
}
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("GetMailMessage: %v", err)
}
if detail["subject"] == nil {
t.Fatalf("message missing subject: %+v", detail)
}
}
+382
View File
@@ -0,0 +1,382 @@
package fetch
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct {
Client *onlyoffice.Client
}
// List returns items for the given list spec (nav leaf).
func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
switch spec.Subject {
case model.SubjectProjects:
return l.listProjects(ctx)
case model.SubjectTasks:
if spec.ProjectID != "" {
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx)
case model.SubjectCalendars:
return l.listCalendars(ctx)
case model.SubjectEvents:
return l.listEvents(ctx)
case model.SubjectContacts:
return l.listContacts(ctx, nil)
case model.SubjectPersons:
falseVal := false
return l.listContacts(ctx, &falseVal)
case model.SubjectCompanies:
trueVal := true
return l.listContacts(ctx, &trueVal)
case model.SubjectOpportunities:
return l.listOpportunities(ctx)
case model.SubjectCases:
return l.listCases(ctx)
case model.SubjectCRMTasks:
return l.listCRMTasks(ctx)
case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox)
case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent)
case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts)
case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash)
case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam)
case model.SubjectUsers:
return l.listUsers(ctx)
case model.SubjectProjectFiles:
pid := spec.ProjectID
if pid == "" {
pid = onlyoffice.GetEnvironmentDefaults().ProjectID
}
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
}
return l.listProjectFiles(ctx, pid)
case model.SubjectTaskFiles:
if spec.TaskID == "" {
return nil, fmt.Errorf("pick a task under Projects in the tree")
}
return l.listTaskFiles(ctx, spec.TaskID)
default:
return nil, fmt.Errorf("unsupported subject %q", spec.Subject)
}
}
// LoadProjectsForNav returns projects to inject as dynamic tree nodes.
func (l *Loader) LoadProjectsForNav(ctx context.Context) ([]model.Item, error) {
return l.listProjects(ctx)
}
// Detail fetches full record data for preview when list row is insufficient.
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
switch item.Kind {
case model.KindOpportunity:
return l.Client.GetOpportunity(ctx, item.ID)
case model.KindContact:
return l.Client.GetContact(ctx, item.ID)
case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask, model.KindCRMTask:
return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID)
default:
if item.Raw != nil {
return item.Raw, nil
}
return map[string]any{"id": item.ID, "title": item.Title}, nil
}
}
// Execute runs a user-selected action on an item.
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
switch action {
case model.ActionView:
return "view", nil
case model.ActionDelete:
return l.executeDelete(ctx, item)
case model.ActionDownload:
return l.executeDownload(ctx, item, destPath)
default:
return "", fmt.Errorf("unsupported action %q", action)
}
}
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
switch item.Kind {
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.DeleteProject(id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted project %s", item.Title), nil
case model.KindTask:
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted task %s", item.Title), nil
case model.KindContact:
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted contact %s", item.Title), nil
case model.KindOpportunity:
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted deal %s", item.Title), nil
case model.KindCase:
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted case %s", item.Title), nil
case model.KindCRMTask:
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
case model.KindMail:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted message %s", item.Title), nil
case model.KindFile:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
return "", err
}
return fmt.Sprintf("Deleted file %s", item.Title), nil
default:
return "", fmt.Errorf("delete not supported for %s", item.Kind)
}
}
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
if item.Kind != model.KindFile {
return "", fmt.Errorf("download only for files")
}
if destPath == "" {
destPath = filepath.Join(os.TempDir(), "office", item.Title)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", err
}
f, err := os.Create(destPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
return "", err
}
return fmt.Sprintf("Downloaded to %s", destPath), nil
}
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
rows, err := l.Client.ListTasks(ctx, projectID, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
files, err := l.Client.GetTaskFiles(ctx, taskID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
projects, err := l.Client.GetProjects()
if err != nil {
return nil, err
}
items := make([]model.Item, len(projects))
for i, p := range projects {
id := ""
if p.ID != nil {
id = fmt.Sprint(*p.ID)
}
title := ""
if p.Title != nil {
title = *p.Title
}
status := ""
if p.Status != nil {
status = fmt.Sprint(*p.Status)
}
items[i] = model.Item{
ID: id, Title: title, Subtitle: status, Kind: model.KindProject,
Raw: map[string]any{"id": id, "title": title, "status": status},
}
}
return items, nil
}
func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllTasks(ctx, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listCalendars(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListCalendars(ctx, "", "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCalendar, FieldMap{IDKey: "objectId", TitleKey: "title"}), nil
}
func (l *Loader) listEvents(ctx context.Context) ([]model.Item, error) {
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
rows, err := l.Client.ListEvents(ctx, start, end)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindEvent, FieldMap{IDKey: "objectId", TitleKey: "title", SubtitleKey: "start"}), nil
}
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx)
if err != nil {
return nil, err
}
if companyOnly != nil {
filtered := make([]map[string]any, 0, len(rows))
for _, r := range rows {
isCo, _ := r["isCompany"].(bool)
if isCo == *companyOnly {
filtered = append(filtered, r)
}
}
rows = filtered
}
return ItemsFromMaps(rows, model.KindContact, ContactItemFields), nil
}
func (l *Loader) listOpportunities(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllOpportunities(ctx)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindOpportunity, FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "stage"}), nil
}
func (l *Loader) listCases(ctx context.Context) ([]model.Item, error) {
cases, _, err := l.Client.ListCases(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(cases, model.KindCase, FieldMap{IDKey: "id", TitleKey: "title"}), nil
}
func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
rows, _, err := l.Client.ListCRMTasks(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
}
func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{Folder: folder, Count: 50})
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
}
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 {
return nil, err
}
var items []model.Item
for _, f := range resp.Files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
+78
View File
@@ -0,0 +1,78 @@
package fetch
import (
"fmt"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// FieldMap names the JSON keys used when building list items from API rows.
type FieldMap struct {
IDKey string
TitleKey string
SubtitleKey string
}
// TaskItemFields is the default field map for project tasks.
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
// ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
// ContactItemFields is the default field map for CRM contacts.
var ContactItemFields = FieldMap{IDKey: "id", TitleKey: "displayName", SubtitleKey: "primaryEmail"}
// MailItemFields is the default field map for mail messages.
var MailItemFields = FieldMap{IDKey: "id", TitleKey: "subject", SubtitleKey: "from"}
// ItemsFromMaps converts OnlyOffice list rows into TUI items.
func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []model.Item {
out := make([]model.Item, len(rows))
for i, row := range rows {
title := str(row, fields.TitleKey)
if title == "" {
title = str(row, "title")
}
if title == "" {
title = str(row, "name")
}
if title == "" {
title = "(untitled)"
}
raw := row
out[i] = model.Item{
ID: idStr(row, fields.IDKey),
Title: title,
Subtitle: str(row, fields.SubtitleKey),
Kind: kind,
Raw: raw,
}
}
return out
}
func idStr(m map[string]any, key string) string {
if key == "" {
key = "id"
}
switch v := m[key].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprint(m[key])
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
+34
View File
@@ -0,0 +1,34 @@
// Command office is a terminal UI for OnlyOffice Workspace.
//
// Three-pane layout: module tree (left), selectable list (center),
// markdown preview (right). Uses github.com/eslider/go-onlyoffice for API calls.
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/office@latest
package main
import (
"context"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func main() {
client, err := bootstrap.NewClient(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := ui.NewModel(client)
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+64
View File
@@ -0,0 +1,64 @@
package model
// ActionID identifies an operation on a list item.
type ActionID string
const (
ActionView ActionID = "view"
ActionDelete ActionID = "delete"
ActionRefresh ActionID = "refresh"
ActionDownload ActionID = "download"
)
// ItemAction is one CRUD-style operation offered for an item.
type ItemAction struct {
ID ActionID
Label string
Danger bool
}
// ActionsFor returns available operations for an item kind.
func ActionsFor(kind Kind) []ItemAction {
switch kind {
case KindProject:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete project", Danger: true},
}
case KindTask, KindCRMTask:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete task", Danger: true},
}
case KindContact:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete contact", Danger: true},
}
case KindOpportunity:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete deal", Danger: true},
}
case KindCase:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete case", Danger: true},
}
case KindMail:
return []ItemAction{
{ID: ActionView, Label: "Read message"},
{ID: ActionDelete, Label: "Delete message", Danger: true},
}
case KindFile:
return []ItemAction{
{ID: ActionView, Label: "Preview file"},
{ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true},
}
case KindEvent, KindCalendar, KindUser:
return []ItemAction{{ID: ActionView, Label: "View details"}}
default:
return []ItemAction{{ID: ActionView, Label: "View details"}}
}
}
+126
View File
@@ -0,0 +1,126 @@
package model
// Kind identifies the OnlyOffice entity type for list items and preview routing.
type Kind string
const (
KindProject Kind = "project"
KindTask Kind = "task"
KindContact Kind = "contact"
KindOpportunity Kind = "opportunity"
KindCase Kind = "case"
KindCRMTask Kind = "crm_task"
KindMail Kind = "mail"
KindEvent Kind = "event"
KindCalendar Kind = "calendar"
KindFile Kind = "file"
KindUser Kind = "user"
)
// Subject identifies a menu leaf / list source.
type Subject string
const (
SubjectProjects Subject = "projects"
SubjectTasks Subject = "tasks"
SubjectCalendars Subject = "calendars"
SubjectEvents Subject = "events"
SubjectContacts Subject = "contacts"
SubjectPersons Subject = "persons"
SubjectCompanies Subject = "companies"
SubjectOpportunities Subject = "opportunities"
SubjectCases Subject = "cases"
SubjectCRMTasks Subject = "crm_tasks"
SubjectMailInbox Subject = "mail_inbox"
SubjectMailSent Subject = "mail_sent"
SubjectMailDrafts Subject = "mail_drafts"
SubjectMailTrash Subject = "mail_trash"
SubjectMailSpam Subject = "mail_spam"
SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users"
)
// FocusPane is which column has keyboard focus.
type FocusPane int
const (
FocusMenu FocusPane = iota
FocusList
FocusPreview
)
// PrevFocusPane cycles preview → list → menu → preview.
func PrevFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusPreview
case FocusList:
return FocusMenu
default:
return FocusList
}
}
// NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusList
case FocusList:
return FocusPreview
default:
return FocusMenu
}
}
// Item is one row in the center list pane.
type Item struct {
ID, Title, Subtitle string
Kind Kind
Raw map[string]any
Selected bool
}
// Selection tracks multi-selected item IDs within the current subject.
type Selection struct {
ids map[string]struct{}
}
// NewSelection returns an empty selection set.
func NewSelection() *Selection {
return &Selection{ids: make(map[string]struct{})}
}
// Toggle flips selection on items[idx] and updates the ID set.
func (s *Selection) Toggle(items *[]Item, idx int) {
if idx < 0 || idx >= len(*items) {
return
}
it := &(*items)[idx]
it.Selected = !it.Selected
if it.Selected {
s.ids[it.ID] = struct{}{}
} else {
delete(s.ids, it.ID)
}
}
// Count returns the number of selected items.
func (s *Selection) Count() int {
return len(s.ids)
}
// Clear removes all selections.
func (s *Selection) Clear() {
s.ids = make(map[string]struct{})
}
// IDs returns selected item IDs in stable order (map iteration order is fine for display).
func (s *Selection) IDs() []string {
out := make([]string, 0, len(s.ids))
for id := range s.ids {
out = append(out, id)
}
return out
}
+45
View File
@@ -0,0 +1,45 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestSelectionToggle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "A"},
{ID: "2", Title: "B"},
}
sel := model.NewSelection()
if sel.Count() != 0 {
t.Fatalf("initial count=%d", sel.Count())
}
sel.Toggle(&items, 0)
if !items[0].Selected || sel.Count() != 1 {
t.Fatalf("after toggle 0: selected=%v count=%d", items[0].Selected, sel.Count())
}
sel.Toggle(&items, 0)
if items[0].Selected || sel.Count() != 0 {
t.Fatalf("after second toggle: selected=%v count=%d", items[0].Selected, sel.Count())
}
}
func TestSelectionClearOnSubjectChange(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A", Selected: true}}
sel := model.NewSelection()
sel.Toggle(&items, 0)
sel.Clear()
if sel.Count() != 0 {
t.Fatalf("count=%d after clear", sel.Count())
}
}
func TestNextFocusPane(t *testing.T) {
if got := model.NextFocusPane(model.FocusMenu); got != model.FocusList {
t.Fatalf("menu->list got %v", got)
}
if got := model.NextFocusPane(model.FocusPreview); got != model.FocusMenu {
t.Fatalf("preview->menu got %v", got)
}
}
+209
View File
@@ -0,0 +1,209 @@
package model
// ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct {
Subject Subject
ProjectID string
TaskID string
}
// NavNode is one row in the navigation tree.
type NavNode struct {
ID string
Label string
Branch bool
List *ListSpec // set on leaves that open the center list
ParentID string
}
// NavTree is a hierarchical navigator; the center list loads only on leaves.
type NavTree struct {
nodes map[string]NavNode
roots []string
children map[string][]string
expanded map[string]bool
cursor int
visible []string
}
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
func DefaultNavTree() *NavTree {
t := &NavTree{
nodes: make(map[string]NavNode),
children: make(map[string][]string),
expanded: make(map[string]bool),
}
add := func(id, label, parent string, branch bool, list *ListSpec) {
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
if parent == "" {
t.roots = append(t.roots, id)
} else {
t.children[parent] = append(t.children[parent], id)
}
}
add("projects", "Projects", "", true, nil)
add("projects.browse", "Browse", "projects", true, nil)
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects})
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "projects", true, nil)
add("calendar", "Calendar", "", true, nil)
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
add("mail", "Mail", "", true, nil)
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", true, nil)
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
t.rebuildVisible()
return t
}
// RootLabels returns top-level labels (for tests).
func (t *NavTree) RootLabels() []string {
out := make([]string, len(t.roots))
for i, id := range t.roots {
out[i] = t.nodes[id].Label
}
return out
}
func (t *NavTree) Cursor() int { return t.cursor }
func (t *NavTree) VisibleCount() int { return len(t.visible) }
func (t *NavTree) NodeAtVisible(i int) (NavNode, bool) {
if i < 0 || i >= len(t.visible) {
return NavNode{}, false
}
n, ok := t.nodes[t.visible[i]]
return n, ok
}
func (t *NavTree) DepthAtVisible(i int) int {
if i < 0 || i >= len(t.visible) {
return 0
}
depth := 0
id := t.visible[i]
for {
n, ok := t.nodes[id]
if !ok || n.ParentID == "" {
break
}
depth++
id = n.ParentID
}
return depth
}
func (t *NavTree) IsExpandable(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return n.Branch && len(t.children[n.ID]) > 0
}
func (t *NavTree) IsExpanded(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return t.expanded[n.ID]
}
func (t *NavTree) ToggleExpand(i int) {
n, ok := t.NodeAtVisible(i)
if !ok || !n.Branch {
return
}
if t.expanded[n.ID] {
delete(t.expanded, n.ID)
} else {
t.expanded[n.ID] = true
}
t.rebuildVisible()
}
func (t *NavTree) MoveUp() { t.SetCursor(t.cursor - 1) }
func (t *NavTree) MoveDown() { t.SetCursor(t.cursor + 1) }
func (t *NavTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(t.visible) {
c = len(t.visible) - 1
}
if c < 0 {
c = 0
}
t.cursor = c
}
// CurrentListSpec returns the list spec when the cursor is on a leaf.
func (t *NavTree) CurrentListSpec() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok || n.List == nil {
return nil, false
}
spec := *n.List
return &spec, true
}
// Activate expands a branch or returns leaf list spec on Enter.
func (t *NavTree) Activate() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok {
return nil, false
}
if n.Branch {
t.expanded[n.ID] = true
t.rebuildVisible()
return nil, false
}
if n.List != nil {
spec := *n.List
return &spec, true
}
return nil, false
}
func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0]
var walk func(id string, depth int)
walk = func(id string, depth int) {
t.visible = append(t.visible, id)
if !t.expanded[id] {
return
}
for _, child := range t.children[id] {
walk(child, depth+1)
}
}
for _, root := range t.roots {
walk(root, 0)
}
if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1
}
if t.cursor < 0 {
t.cursor = 0
}
}
+31
View File
@@ -0,0 +1,31 @@
package model
import "strings"
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
func (t *NavTree) InjectProjectNodes(projects []Item) {
// Drop prior dynamic project.* nodes.
for id := range t.nodes {
if strings.HasPrefix(id, "project.") {
delete(t.nodes, id)
}
}
t.children["projects.dynamic"] = nil
for _, p := range projects {
pid := "project." + p.ID
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
taskID := pid + ".tasks"
fileID := pid + ".files"
t.nodes[taskID] = NavNode{
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
}
t.nodes[fileID] = NavNode{
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
}
t.children[pid] = []string{taskID, fileID}
}
t.rebuildVisible()
}
+75
View File
@@ -0,0 +1,75 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree()
if !tree.IsExpandable(0) {
t.Fatal("Projects should be expandable")
}
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
}
tree.ToggleExpand(0)
if tree.IsExpanded(0) {
t.Fatal("Projects should collapse")
}
}
func TestNavLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.ToggleExpand(0) // Projects
tree.ToggleExpand(1) // Browse
// Find "All projects" leaf cursor
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.List != nil && n.List.Subject == model.SubjectProjects {
found = i
break
}
}
if found < 0 {
t.Fatal("all projects leaf not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
}
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) < 2 {
t.Fatalf("expected view+delete, got %d", len(acts))
}
}
+10
View File
@@ -0,0 +1,10 @@
package preview
import (
md "github.com/JohannesKaufmann/html-to-markdown/v2"
)
// HTMLToMarkdown converts Document Server HTML output to markdown for TUI preview.
func HTMLToMarkdown(html string) (string, error) {
return md.ConvertString(html)
}
+19
View File
@@ -0,0 +1,19 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestHTMLToMarkdown(t *testing.T) {
html := `<h1>Title</h1><p>Body text</p>`
md, err := preview.HTMLToMarkdown(html)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "Title") || !strings.Contains(md, "Body text") {
t.Fatalf("conversion failed: %q", md)
}
}
+168
View File
@@ -0,0 +1,168 @@
package preview
import (
"fmt"
"html"
"regexp"
"strings"
)
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
// ContactMarkdown formats a CRM contact or company for preview.
func ContactMarkdown(m map[string]any) string {
name := str(m, "displayName")
if name == "" {
name = strings.TrimSpace(str(m, "firstName") + " " + str(m, "lastName"))
}
kind := "Person"
if boolVal(m, "isCompany") {
kind = "Company"
}
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", name)
fmt.Fprintf(&b, "**Type:** %s\n\n", kind)
if about := str(m, "about"); about != "" {
fmt.Fprintf(&b, "## About\n\n%s\n\n", about)
}
appendInfoList(&b, m)
return strings.TrimSpace(b.String()) + "\n"
}
// OpportunityMarkdown formats a CRM deal for preview.
func OpportunityMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if v := m["bidValue"]; v != nil {
fmt.Fprintf(&b, "**Value:** %v %s\n\n", v, str(m, "bidCurrency"))
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// MailMarkdown formats a mail message for preview.
func MailMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from"))
if to := str(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to)
}
body := str(m, "body")
if body == "" {
body = str(m, "htmlBody")
}
body = stripHTML(body)
if body != "" {
fmt.Fprintf(&b, "## Body\n\n%s\n\n", body)
}
return strings.TrimSpace(b.String()) + "\n"
}
// EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
fmt.Fprintf(&b, "**Start:** %s\n\n", str(m, "start"))
fmt.Fprintf(&b, "**End:** %s\n\n", str(m, "end"))
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// TaskMarkdown formats a project or CRM task for preview.
func TaskMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "deadline"); d != "" {
fmt.Fprintf(&b, "**Deadline:** %s\n\n", d)
}
if desc := str(m, "description"); desc != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", desc)
}
return strings.TrimSpace(b.String()) + "\n"
}
// ProjectMarkdown formats a project for preview.
func ProjectMarkdown(m map[string]any) string {
var b strings.Builder
title := str(m, "title")
if title == "" {
title = str(m, "name")
}
fmt.Fprintf(&b, "# %s\n\n", title)
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// EntityMarkdown picks a formatter based on item kind.
func EntityMarkdown(kind string, m map[string]any) string {
switch kind {
case "contact", "person", "company":
return ContactMarkdown(m)
case "opportunity":
return OpportunityMarkdown(m)
case "mail":
return MailMarkdown(m)
case "event":
return EventMarkdown(m)
case "task", "crm_task":
return TaskMarkdown(m)
case "project":
return ProjectMarkdown(m)
default:
return mapToMarkdown(m)
}
}
func mapToMarkdown(m map[string]any) string {
var b strings.Builder
b.WriteString("# Details\n\n")
for k, v := range m {
fmt.Fprintf(&b, "- **%s:** %v\n", k, v)
}
return b.String()
}
func appendInfoList(b *strings.Builder, m map[string]any) {
if infos, ok := m["contactInfos"].([]any); ok && len(infos) > 0 {
b.WriteString("## Contact info\n\n")
for _, raw := range infos {
if row, ok := raw.(map[string]any); ok {
fmt.Fprintf(b, "- %s: %s\n", str(row, "infoType"), str(row, "data"))
}
}
b.WriteString("\n")
}
}
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
func boolVal(m map[string]any, key string) bool {
v, ok := m[key].(bool)
return ok && v
}
@@ -0,0 +1,189 @@
//go:build integration
package preview_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func skipWithoutLiveAPI(t *testing.T) {
t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
}
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
t.Helper()
skipWithoutLiveAPI(t)
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
return &fetch.Loader{Client: client}, context.Background()
}
func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.EntityMarkdown(string(model.KindProject), raw)
if md == "" {
t.Fatal("empty markdown")
}
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing project title %q:\n%s", items[0].Title, md)
}
rendered, err := preview.RenderMarkdown(md, 80)
if err != nil {
t.Fatal(err)
}
if rendered == "" {
t.Fatal("glamour produced empty output")
}
}
func TestIntegrationPreviewContactFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no contacts")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.ContactMarkdown(raw)
if md == "" || !strings.Contains(md, "#") {
t.Fatalf("unexpected contact markdown: %q", md)
}
}
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no opportunities")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.OpportunityMarkdown(raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing deal title:\n%s", md)
}
}
func TestIntegrationPreviewMailFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("inbox empty")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.MailMarkdown(raw)
if md == "" || !strings.Contains(md, "From:") {
t.Fatalf("unexpected mail markdown:\n%s", md)
}
}
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectEvents})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no events in range")
}
md := preview.EventMarkdown(items[0].Raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing event title:\n%s", md)
}
}
func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.TaskMarkdown(raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing task title:\n%s", md)
}
}
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil {
t.Fatal(err)
}
var csvItem *model.Item
for i := range items {
if strings.HasSuffix(strings.ToLower(items[i].Title), ".csv") {
csvItem = &items[i]
break
}
}
if csvItem == nil {
t.Skip("no csv file in project documents")
}
var buf strings.Builder
n, err := loader.Client.DownloadFile(ctx, csvItem.ID, &buf)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if n == 0 {
t.Fatal("empty download")
}
md, err := preview.CSVToMarkdownTable([]byte(buf.String()))
if err != nil {
t.Fatalf("CSVToMarkdownTable: %v", err)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
+23
View File
@@ -0,0 +1,23 @@
package preview
import (
"github.com/charmbracelet/glamour"
)
// RenderMarkdown renders GitHub-flavoured markdown for terminal display.
func RenderMarkdown(md string, width int) (string, error) {
if md == "" {
return "", nil
}
if width < 20 {
width = 20
}
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(width),
)
if err != nil {
return "", err
}
return r.Render(md)
}
+31
View File
@@ -0,0 +1,31 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestRenderMarkdown(t *testing.T) {
out, err := preview.RenderMarkdown("# Hello\n\nWorld", 40)
if err != nil {
t.Fatal(err)
}
if out == "" {
t.Fatal("empty render output")
}
if !strings.Contains(strings.ToLower(out), "hello") {
t.Fatalf("expected rendered heading: %q", out)
}
}
func TestRenderMarkdownEmpty(t *testing.T) {
out, err := preview.RenderMarkdown("", 40)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("expected empty, got %q", out)
}
}
+65
View File
@@ -0,0 +1,65 @@
package preview
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"strings"
xls "github.com/eslider/go-xls/v2"
)
// CSVToMarkdownTable parses CSV bytes into a GitHub pipe table markdown string.
func CSVToMarkdownTable(data []byte) (string, error) {
tab, err := parseCSV(data)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := xls.WriteMarkdownTable(&buf, tab); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()) + "\n", nil
}
func parseCSV(data []byte) (xls.Table, error) {
r := csv.NewReader(bytes.NewReader(data))
r.TrimLeadingSpace = true
records, err := r.ReadAll()
if err != nil {
return xls.Table{}, err
}
if len(records) == 0 {
return xls.Table{}, fmt.Errorf("preview: empty csv")
}
return xls.Table{Columns: records[0], Rows: records[1:]}, nil
}
// JSONToMarkdown wraps JSON in a fenced code block for preview.
func JSONToMarkdown(data []byte) (string, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return "", err
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("```json\n%s\n```\n", string(pretty)), nil
}
// FileBytesToMarkdown picks a preview format based on file extension.
func FileBytesToMarkdown(name string, data []byte) (string, error) {
ext := strings.ToLower(name)
switch {
case strings.HasSuffix(ext, ".csv"):
return CSVToMarkdownTable(data)
case strings.HasSuffix(ext, ".json"):
return JSONToMarkdown(data)
case strings.HasSuffix(ext, ".md"), strings.HasSuffix(ext, ".txt"):
return string(data), nil
default:
return fmt.Sprintf("```\n(binary or unsupported preview for %s, %d bytes)\n```\n", name, len(data)), nil
}
}
+32
View File
@@ -0,0 +1,32 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestCSVToMarkdownTable(t *testing.T) {
csv := "name,score\nAlice,10\nBob,20\n"
md, err := preview.CSVToMarkdownTable([]byte(csv))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "name") || !strings.Contains(md, "Alice") {
t.Fatalf("table missing data: %q", md)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
func TestJSONToMarkdown(t *testing.T) {
md, err := preview.JSONToMarkdown([]byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "```") || !strings.Contains(md, `"a"`) {
t.Fatalf("expected fenced json: %q", md)
}
}
+521
View File
@@ -0,0 +1,521 @@
package ui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
type listLoadedMsg struct {
items []model.Item
spec model.ListSpec
err error
}
type previewLoadedMsg struct {
text string
err error
}
type actionDoneMsg struct {
message string
err error
}
type navProjectsMsg struct {
projects []model.Item
err error
}
// Model is the root Bubble Tea model for the office TUI.
type Model struct {
client *onlyoffice.Client
loader *fetch.Loader
nav *model.NavTree
listSpec model.ListSpec
hasList bool
items []model.Item
selection *model.Selection
listIdx int
focus model.FocusPane
width int
height int
status string
err string
loading bool
menuVP viewport.Model
listVP viewport.Model
previewVP viewport.Model
previewMD string
// Action menu overlay on list pane
actionMode bool
actionIdx int
itemActions []model.ItemAction
}
// NewModel constructs the TUI with an authenticated client.
func NewModel(client *onlyoffice.Client) Model {
return Model{
client: client,
loader: &fetch.Loader{Client: client},
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit",
}
}
func (m Model) Init() tea.Cmd {
return m.loadNavProjectsCmd()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.layoutViewports()
m.syncAllContent()
return m, nil
case tea.KeyMsg:
key := msg.String()
if key == "?" {
m.status = helpText()
return m, nil
}
if m.actionMode && m.focus == model.FocusList {
return m.handleActionKey(key)
}
action := KeyAction(key, m.focus)
if action == ActionNone && (key == "up" || key == "k") {
action = ResolveMoveUp(m.focus)
}
switch action {
case ActionQuit:
return m, tea.Quit
case ActionNextPane:
m.focus = model.NextFocusPane(m.focus)
return m, nil
case ActionPrevPane:
m.focus = model.PrevFocusPane(m.focus)
return m, nil
case ActionMoveUp:
m.moveUp()
m.syncAllContent()
return m, nil
case ActionMoveDown:
m.moveDown()
m.syncAllContent()
return m, nil
case ActionToggleSelect:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx)
m.syncListContent()
}
return m, nil
case ActionOpenActions:
if m.hasList && len(m.items) > 0 {
m.openActionMenu()
m.syncListContent()
}
return m, nil
case ActionOpenPreview:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd()
}
return m, nil
case ActionRefresh:
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
}
return m, nil
}
if m.focus == model.FocusMenu {
switch key {
case " ":
m.nav.ToggleExpand(m.nav.Cursor())
m.syncMenuContent()
return m, nil
case "enter":
if spec, ok := m.nav.Activate(); ok {
return m.withList(*spec)
}
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
}
}
if m.focus == model.FocusList && key == "enter" && m.hasList {
m.openActionMenu()
m.syncListContent()
return m, nil
}
case listLoadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err.Error()
m.items = nil
m.hasList = false
} else {
m.err = ""
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listIdx = 0
}
m.syncListContent()
return m, nil
case previewLoadedMsg:
m.loading = false
if msg.err != nil {
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error())
} else {
m.previewMD = msg.text
}
m.syncPreviewContent()
return m, nil
case actionDoneMsg:
m.loading = false
m.actionMode = false
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.status = msg.message
m.err = ""
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
}
}
return m, nil
case navProjectsMsg:
if msg.err == nil {
m.nav.InjectProjectNodes(msg.projects)
m.syncMenuContent()
}
return m, nil
}
var cmd tea.Cmd
switch m.focus {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
m.listVP, cmd = m.listVP.Update(msg)
case model.FocusPreview:
m.previewVP, cmd = m.previewVP.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if m.width == 0 {
return "Loading…\n"
}
menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 2
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h)
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h)
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h)
status := m.status
if m.loading {
status = "Loading…"
}
if m.err != "" {
status = "Error: " + m.err
}
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
return lipgloss.JoinHorizontal(lipgloss.Top,
menuStyle.Render(m.menuVP.View()),
listStyle.Render(m.listVP.View()),
prevStyle.Render(m.previewVP.View()),
) + "\n" + bar
}
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec
m.selection.Clear()
m.listIdx = 0
m.hasList = false
m.actionMode = false
m.focus = model.FocusList
m.loading = true
return m, m.loadListCmd(spec)
}
func (m *Model) openActionMenu() {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return
}
m.itemActions = model.ActionsFor(m.items[m.listIdx].Kind)
m.actionIdx = 0
m.actionMode = true
}
func (m *Model) handleActionKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "esc":
m.actionMode = false
m.syncListContent()
return m, nil
case "up", "k":
if m.actionIdx > 0 {
m.actionIdx--
}
m.syncListContent()
return m, nil
case "down", "j":
if m.actionIdx < len(m.itemActions)-1 {
m.actionIdx++
}
m.syncListContent()
return m, nil
case "enter":
if m.listIdx < 0 || m.listIdx >= len(m.items) || m.actionIdx >= len(m.itemActions) {
return m, nil
}
act := m.itemActions[m.actionIdx]
item := m.items[m.listIdx]
m.actionMode = false
if act.ID == model.ActionView {
return m, m.loadPreviewCmd()
}
m.loading = true
return m, m.executeActionCmd(act.ID, item)
}
return m, nil
}
func (m *Model) layoutViewports() {
menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 4
if h < 4 {
h = 4
}
inner := func(w int) int {
if w > 4 {
return w - 2
}
return w
}
m.menuVP = viewport.New(inner(menuW), h)
m.listVP = viewport.New(inner(listW), h)
m.previewVP = viewport.New(inner(prevW), h)
}
func (m *Model) syncAllContent() {
m.syncMenuContent()
m.syncListContent()
m.syncPreviewContent()
}
func (m *Model) syncMenuContent() {
var b strings.Builder
b.WriteString("Navigation\n\n")
for i := 0; i < m.nav.VisibleCount(); i++ {
n, _ := m.nav.NodeAtVisible(i)
depth := m.nav.DepthAtVisible(i)
prefix := strings.Repeat(" ", depth)
line := prefix + n.Label
if m.nav.IsExpandable(i) {
if m.nav.IsExpanded(i) {
line = prefix + "▾ " + n.Label
} else {
line = prefix + "▸ " + n.Label
}
} else if n.List != nil {
line = prefix + "• " + n.Label
}
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
line = "> " + line
} else {
line = " " + line
}
b.WriteString(line + "\n")
}
m.menuVP.SetContent(b.String())
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
}
func (m *Model) syncListContent() {
var b strings.Builder
if !m.hasList {
b.WriteString("List\n\n")
b.WriteString("Select a leaf node in the tree\n")
b.WriteString("(marked with •) and press Enter.\n")
} else {
fmt.Fprintf(&b, "%s (%d)\n\n", m.listSpec.Subject, len(m.items))
if m.actionMode {
item := m.items[m.listIdx]
fmt.Fprintf(&b, "Actions for: %s\n\n", item.Title)
for i, act := range m.itemActions {
cursor := " "
if i == m.actionIdx {
cursor = "> "
}
label := act.Label
if act.Danger {
label = "⚠ " + label
}
fmt.Fprintf(&b, "%s%s\n", cursor, label)
}
b.WriteString("\nEnter: run · Esc: cancel\n")
} else {
for i, it := range m.items {
mark := "[ ]"
if it.Selected {
mark = "[x]"
}
cursor := " "
if i == m.listIdx && m.focus == model.FocusList {
cursor = "> "
}
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
if it.Subtitle != "" {
line += " — " + it.Subtitle
}
b.WriteString(line + "\n")
}
if len(m.items) == 0 && !m.loading {
b.WriteString("(empty)\n")
}
b.WriteString("\nEnter/a: actions · Space: select\n")
}
}
m.listVP.SetContent(b.String())
line := m.listIdx + 3
if m.actionMode {
line = m.actionIdx + 4
}
syncVPToLine(&m.listVP, line)
}
func (m *Model) syncPreviewContent() {
menuW, listW, prevW := LayoutWidths(m.width)
_ = menuW
_ = listW
w := prevW - 4
if w < 20 {
w = 20
}
text, err := preview.RenderMarkdown(m.previewMD, w)
if err != nil {
text = m.previewMD
}
m.previewVP.SetContent(text)
}
func syncVPToLine(vp *viewport.Model, line int) {
if line < 0 {
line = 0
}
if line < vp.YOffset {
vp.YOffset = line
} else if line >= vp.YOffset+vp.Height {
vp.YOffset = line - vp.Height + 1
}
if vp.YOffset < 0 {
vp.YOffset = 0
}
}
func (m *Model) moveUp() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveUp()
case model.FocusList:
if m.hasList && !m.actionMode && m.listIdx > 0 {
m.listIdx--
}
case model.FocusPreview:
m.previewVP.LineUp(1)
}
}
func (m *Model) moveDown() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveDown()
case model.FocusList:
if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
m.listIdx++
}
case model.FocusPreview:
m.previewVP.LineDown(1)
}
}
func (m *Model) loadListCmd(spec model.ListSpec) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.List(ctx, spec)
return listLoadedMsg{items: items, spec: spec, err: err}
}
}
func (m *Model) loadPreviewCmd() tea.Cmd {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return nil
}
item := m.items[m.listIdx]
return func() tea.Msg {
ctx := context.Background()
raw, err := m.loader.Detail(ctx, item)
if err != nil {
return previewLoadedMsg{err: err}
}
md := preview.EntityMarkdown(string(item.Kind), raw)
return previewLoadedMsg{text: md}
}
}
func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
msg, err := m.loader.Execute(ctx, action, item, "")
return actionDoneMsg{message: msg, err: err}
}
}
func (m *Model) loadNavProjectsCmd() tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
projects, err := m.loader.LoadProjectsForNav(ctx)
return navProjectsMsg{projects: projects, err: err}
}
}
func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if focused {
return s.Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62"))
}
return s.Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("238"))
}
func helpText() string {
return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit"
}
+42
View File
@@ -0,0 +1,42 @@
package ui_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestKeyActionMenuDown(t *testing.T) {
if got := ui.KeyAction("j", model.FocusMenu); got != ui.ActionMoveDown {
t.Fatalf("got %v", got)
}
}
func TestKeyActionShiftTabPrevPane(t *testing.T) {
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
t.Fatalf("got %v", got)
}
if got := ui.KeyAction("backtab", model.FocusPreview); got != ui.ActionPrevPane {
t.Fatalf("got %v", got)
}
}
func TestKeyActionTabNextPane(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane {
t.Fatalf("got %v", got)
}
}
func TestKeyActionOpenActions(t *testing.T) {
if got := ui.KeyAction("a", model.FocusList); got != ui.ActionOpenActions {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidths(120)
if menu+list+preview > 120 {
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
}
}
+85
View File
@@ -0,0 +1,85 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// Action is a keyboard command outcome for the TUI.
type Action int
const (
ActionNone Action = iota
ActionMoveUp
ActionMoveDown
ActionToggleSelect
ActionOpenPreview
ActionOpenActions
ActionNextPane
ActionPrevPane
ActionRefresh
ActionQuit
ActionOpenVex
)
// KeyAction maps a key string and focused pane to an action.
func KeyAction(key string, pane model.FocusPane) Action {
switch key {
case "q", "ctrl+c":
return ActionQuit
case "tab":
return ActionNextPane
case "shift+tab", "backtab":
return ActionPrevPane
case "r":
return ActionRefresh
case "up", "k":
return ActionMoveUp
case "down", "j":
return ActionMoveDown
case " ":
if pane == model.FocusList {
return ActionToggleSelect
}
case "a":
if pane == model.FocusList {
return ActionOpenActions
}
case "enter":
if pane == model.FocusList {
return ActionOpenPreview
}
case "v":
if pane == model.FocusList {
return ActionOpenVex
}
}
return ActionNone
}
// LayoutWidths splits total terminal width into menu, list, preview columns.
func LayoutWidths(total int) (menu, list, preview int) {
if total < 80 {
total = 80
}
menu = total / 5
if menu < 22 {
menu = 22
}
preview = total / 3
if preview < 28 {
preview = 28
}
list = total - menu - preview - 2
if list < 24 {
list = 24
}
return menu, list, preview
}
// ResolveMoveUp returns the action for upward navigation keys.
func ResolveMoveUp(pane model.FocusPane) Action {
switch pane {
case model.FocusMenu, model.FocusList, model.FocusPreview:
return ActionMoveUp
default:
return ActionNone
}
}
-47
View File
@@ -1,47 +0,0 @@
# oo-cli
Cobra CLI wrapper around [`github.com/eslider/go-onlyoffice`](../..). Replaces the Python scripts that used to live under `eSlider/cv/bin/office`.
## Install
```bash
go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
```
Ensure `$(go env GOPATH)/bin` is on `PATH`.
## Config
Copy [`../../.env.example`](../../.env.example) to `.env` in the working directory:
| Variable | Alias | Default |
|---|---|---|
| `ONLYOFFICE_URL` | `ONLYOFFICE_HOST` | — |
| `ONLYOFFICE_USER` | `ONLYOFFICE_NAME` | — |
| `ONLYOFFICE_PASS` | `ONLYOFFICE_PASSWORD` | — |
| `ONLYOFFICE_CALENDAR_ID` | — | `1` |
| `ONLYOFFICE_PROJECT_ID` | `ONLYOFFICE_CALENDAR_PROJECT_ID` | `33` |
## Commands
| Python (legacy) | Go CLI |
|---|---|
| `list-calendars.py` | `oo-cli cal-list` |
| `list-events.py` | `oo-cli cal-events [--start] [--end]` |
| `add-event.py` | `oo-cli cal-add TITLE START END [--calendar] [--description] [--all-day]` |
| `delete-event.py` | `oo-cli cal-delete ID [ID...]` |
| `list-tasks.py` | `oo-cli task-list [--project] [--status] [--all] [--verbose]` |
| `add-task.py` | `oo-cli task-add TITLE [--project] [--description] [--deadline] [--priority]` |
| `add-subtask.py` | `oo-cli subtask-add PARENT TITLE [TITLE...]` |
| `update-task.py` | `oo-cli task-update ID open\|closed` or `--delete` |
| `list-contacts.py` | `oo-cli crm-contacts [--companies\|--persons] [--search]` |
| `add-contact.py` | `oo-cli crm-add-contact --company ...` or `--person-first` / `--person-last` |
| `list-deals.py` | `oo-cli crm-deals` / `oo-cli crm-deals --stages` |
| `add-deal.py` | `oo-cli crm-add-deal TITLE [--stage] [--bid] [--contact]` |
| `list-cases.py` | `oo-cli crm-cases` |
| `sync-applications.py` | `oo-cli applications-sync --path .../applications/2026 [--apply]` |
## Notes
- The CLI loads `.env` via `godotenv`; the library itself does not.
- `applications-sync` defaults to dry-run; pass `--apply` to write to CRM.
-20
View File
@@ -1,20 +0,0 @@
// Command oo-cli is a thin CLI wrapper over the github.com/eslider/go-onlyoffice
// library. It exposes calendar, CRM, tasks, subtasks, and application-sync
// commands. Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
package main
import (
"fmt"
"os"
"github.com/eslider/go-onlyoffice/internal/cli"
)
func main() {
if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"time"
"github.com/spf13/cobra"
)
var calendarCmd = &cobra.Command{
Use: "calendar",
Aliases: []string{"cal"},
Short: "Calendars and events",
}
func init() {
rootCmd.AddCommand(calendarCmd)
calendarCmd.AddCommand(calListCmd())
calendarCmd.AddCommand(calEventsCmd())
calendarCmd.AddCommand(calAddCmd())
calendarCmd.AddCommand(calDeleteCmd())
}
func calListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List calendars",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListCalendars(cmd.Context(), "", "")
if err != nil {
return err
}
printTable([]string{"objectId", "title", "textColor", "backgroundColor", "isEditable"}, out)
return nil
},
}
}
func calEventsCmd() *cobra.Command {
var start, end string
cmd := &cobra.Command{
Use: "events",
Short: "List calendar events for a date range (default: next 7 days)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if start == "" || end == "" {
start = time.Now().Format("2006-01-02")
end = time.Now().AddDate(0, 0, 7).Format("2006-01-02")
}
out, err := c.ListEvents(cmd.Context(), start, end)
if err != nil {
return err
}
printTable([]string{"objectId", "title", "start", "end", "allDayLong"}, out)
return nil
},
}
cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD")
cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD")
return cmd
}
func calAddCmd() *cobra.Command {
var cal, desc string
var allDay bool
cmd := &cobra.Command{
Use: "add TITLE START END",
Short: "Add a calendar event",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay)
if err != nil {
return err
}
printObject(ev)
return nil
},
}
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env OO_CALENDAR_ID)")
cmd.Flags().StringVar(&desc, "description", "", "event description")
cmd.Flags().BoolVar(&allDay, "all-day", false, "mark as all-day event")
return cmd
}
func calDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete EVENT_ID [EVENT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more calendar events",
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 {
out, err := c.DeleteEvent(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var casesCmd = &cobra.Command{
Use: "cases",
Aliases: []string{"case"},
Short: "CRM cases",
}
func init() {
rootCmd.AddCommand(casesCmd)
casesCmd.AddCommand(casesListCmd())
casesCmd.AddCommand(casesCreateCmd())
casesCmd.AddCommand(casesDeleteCmd())
casesCmd.AddCommand(casesMemberAddCmd())
}
func casesListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "isClosed", "created"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func casesCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a case",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCase(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func casesDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete CASE_ID [CASE_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more cases",
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 {
out, err := c.DeleteCase(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func casesMemberAddCmd() *cobra.Command {
return &cobra.Command{
Use: "member-add CASE_ID CONTACT_ID",
Short: "Attach a contact to a case",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddCaseMember(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestRootRegistersSubjects(t *testing.T) {
want := []string{
"calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies",
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails",
}
got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() {
got[c.Name()] = true
}
for _, name := range want {
if !got[name] {
t.Fatalf("missing root subcommand %q; have %v", name, rootCmd.Commands())
}
}
}
func TestRootHelpListsSubjects(t *testing.T) {
out := &bytes.Buffer{}
rootCmd.SetOut(out)
rootCmd.SetErr(&bytes.Buffer{})
rootCmd.SetArgs([]string{"--help"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
if err := rootCmd.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet)
}
}
}
func TestProjectsAlias(t *testing.T) {
cmd, _, err := rootCmd.Find([]string{"prj"})
if err != nil {
t.Fatal(err)
}
if cmd.Name() != "projects" {
t.Fatalf("prj alias resolved to %q", cmd.Name())
}
}
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
errBuf := &bytes.Buffer{}
rootCmd.SetErr(errBuf)
rootCmd.SetOut(&bytes.Buffer{})
rootCmd.SetArgs([]string{"users", "list"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
err = rootCmd.Execute()
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/spf13/cobra"
)
// outputFormat is the value of the global --output/-o flag. Valid values:
// "table" (default, tabwriter-rendered), "json" (machine-readable).
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).",
Version: version,
SilenceUsage: true,
SilenceErrors: false,
}
func init() {
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "output format: table|json")
}
// execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() }
// newOO loads env (only .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
return bootstrap.NewClient(cmd.Context())
}
// printJSON dumps any value as indented JSON.
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// printObject renders a single value. JSON output dumps verbatim; table output
// prints key/value pairs when v is a map, otherwise falls back to JSON.
func printObject(v any) {
if outputFormat == "json" {
printJSON(v)
return
}
m, ok := v.(map[string]any)
if !ok {
printJSON(v)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
for _, k := range sortedKeys(m) {
fmt.Fprintf(w, "%s\t%s\n", k, fmtCell(m[k]))
}
_ = w.Flush()
}
// printTable renders a list of rows. headers select & order the columns.
// When outputFormat=="json" the raw slice is dumped as-is.
func printTable(headers []string, rows []map[string]any) {
if outputFormat == "json" {
printJSON(rows)
return
}
if len(rows) == 0 {
fmt.Println("(empty)")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
cells := make([]string, len(headers))
for i, h := range headers {
cells[i] = fmtCell(row[h])
}
fmt.Fprintln(w, strings.Join(cells, "\t"))
}
_ = w.Flush()
}
// fmtCell turns an arbitrary value into a compact table cell.
func fmtCell(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return truncate(x, 80)
case bool:
if x {
return "true"
}
return "false"
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case int, int32, int64:
return fmt.Sprintf("%d", x)
default:
b, _ := json.Marshal(x)
return truncate(string(b), 80)
}
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
func sortedKeys(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
// Deterministic ordering; small map so O(n log n) is fine.
for i := 1; i < len(ks); i++ {
for j := i; j > 0 && ks[j-1] > ks[j]; j-- {
ks[j-1], ks[j] = ks[j], ks[j-1]
}
}
return ks
}
// flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int
// / string into a float64.
func flexIDFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
// idString returns the id field as a string (works for int/float/string).
func idString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
case int:
return strconv.Itoa(x)
default:
return fmt.Sprint(x)
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestFmtCell(t *testing.T) {
tests := []struct {
in any
want string
}{
{nil, ""},
{"hello", "hello"},
{true, "true"},
{false, "false"},
{float64(42), "42"},
{float64(1.5), "1.5"},
{int64(7), "7"},
{map[string]any{"a": 1}, `{"a":1}`},
}
for _, tc := range tests {
if got := fmtCell(tc.in); got != tc.want {
t.Fatalf("fmtCell(%#v)=%q want %q", tc.in, got, tc.want)
}
}
long := strings.Repeat("x", 100)
got := fmtCell(long)
if !strings.HasSuffix(got, "…") || len(got) > 82 {
t.Fatalf("fmtCell(long)=%q len=%d", got, len(got))
}
}
func TestTruncate(t *testing.T) {
if got := truncate("a\nb", 10); got != "a b" {
t.Fatalf("truncate newline=%q", got)
}
got := truncate(strings.Repeat("z", 20), 10)
if !strings.HasSuffix(got, "…") || len(got) >= 20 {
t.Fatalf("truncate=%q len=%d", got, len(got))
}
}
func TestSortedKeys(t *testing.T) {
got := sortedKeys(map[string]any{"c": 1, "a": 2, "b": 3})
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortedKeys=%v want %v", got, want)
}
}
}
func TestFlexIDFloat(t *testing.T) {
if got := flexIDFloat(float64(12)); got != 12 {
t.Fatalf("float64=%v", got)
}
if got := flexIDFloat(7); got != 7 {
t.Fatalf("int=%v", got)
}
if got := flexIDFloat("3.5"); got != 3.5 {
t.Fatalf("string=%v", got)
}
}
func TestIdString(t *testing.T) {
m := map[string]any{"id": float64(99), "s": "x", "i": 5}
if got := idString(m, "id"); got != "99" {
t.Fatalf("float id=%q", got)
}
if got := idString(m, "s"); got != "x" {
t.Fatalf("string id=%q", got)
}
if got := idString(m, "i"); got != "5" {
t.Fatalf("int id=%q", got)
}
if got := idString(m, "missing"); got != "" {
t.Fatalf("missing=%q", got)
}
}
func TestPrintTableJSON(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "json"
out := captureStdout(t, func() {
printTable([]string{"id", "name"}, []map[string]any{{"id": 1, "name": "alpha"}})
})
if !strings.Contains(out, `"id": 1`) || !strings.Contains(out, `"name": "alpha"`) {
t.Fatalf("json output=%q", out)
}
}
func TestPrintTableEmpty(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() { printTable([]string{"id"}, nil) })
if strings.TrimSpace(out) != "(empty)" {
t.Fatalf("empty table=%q", out)
}
}
func TestPrintObjectTable(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() {
printObject(map[string]any{"id": float64(1), "title": "Demo"})
})
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "Demo") {
t.Fatalf("table object=%q", out)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = old
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
_ = r.Close()
return buf.String()
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+313
View File
@@ -0,0 +1,313 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
// Contacts cover both persons and companies; OnlyOffice exposes them through
// the same `crm/contact/*` endpoint family. `oo persons` and `oo companies`
// are filtered views on top of `oo contacts list`, plus dedicated create
// commands that pick the right library call.
var contactsCmd = &cobra.Command{
Use: "contacts",
Aliases: []string{"contact"},
Short: "CRM contacts (persons + companies)",
}
var personsCmd = &cobra.Command{
Use: "persons",
Aliases: []string{"person"},
Short: "CRM persons (contacts with isCompany=false)",
}
var companiesCmd = &cobra.Command{
Use: "companies",
Aliases: []string{"company"},
Short: "CRM companies (contacts with isCompany=true)",
}
func init() {
rootCmd.AddCommand(contactsCmd)
rootCmd.AddCommand(personsCmd)
rootCmd.AddCommand(companiesCmd)
contactsCmd.AddCommand(contactsListCmd(nil))
contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsInfoAddCmd())
contactsCmd.AddCommand(contactsDedupeInfoCmd())
only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(contactsDeleteCmd())
personsCmd.AddCommand(personsDedupeCmd())
onlyCo := false
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
companiesCmd.AddCommand(companiesCreateCmd())
companiesCmd.AddCommand(contactsDeleteCmd())
companiesCmd.AddCommand(companiesDedupeCmd())
companiesCmd.AddCommand(companiesDedupePersonsCmd())
}
// contactsListCmd returns a `list` subcommand.
//
// - personsOnly == nil → list all contacts
// - personsOnly == &true → keep only persons (isCompany=false)
// - personsOnly == &false → keep only companies (isCompany=true)
func contactsListCmd(personsOnly *bool) *cobra.Command {
var search string
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListContacts(cmd.Context(), count, offset, search)
if err != nil {
return err
}
filtered := list
if personsOnly != nil {
filtered = filtered[:0]
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if *personsOnly && isCo {
continue
}
if !*personsOnly && !isCo {
continue
}
filtered = append(filtered, row)
}
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(filtered))
}
printTable([]string{"id", "displayName", "isCompany", "email", "companyName"}, filtered)
return nil
},
}
cmd.Flags().StringVar(&search, "search", "", "search filter")
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func contactsGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get CONTACT_ID",
Short: "Show a contact by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetContact(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func contactsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete CONTACT_ID [CONTACT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more contacts",
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 {
out, err := c.DeleteContact(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func contactsInfoAddCmd() *cobra.Command {
var infoType, value, category string
var isPrimary bool
cmd := &cobra.Command{
Use: "info-add CONTACT_ID",
Short: "Add a contact info entry (email, phone, website, linkedin, …)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if infoType == "" || value == "" {
return fmt.Errorf("--type and --value are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddContactInfo(cmd.Context(), args[0], infoType, value, category, isPrimary)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&infoType, "type", "", "Email|Phone|Website|LinkedIn|…")
cmd.Flags().StringVar(&value, "value", "", "value")
cmd.Flags().StringVar(&category, "category", "Work", "Work|Home|Other")
cmd.Flags().BoolVar(&isPrimary, "primary", false, "mark as primary")
return cmd
}
func personsCreateCmd() *cobra.Command {
var first, last, email, linkedin string
var companyID int
var jobTitle, about string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a person",
RunE: func(cmd *cobra.Command, args []string) error {
if first == "" || last == "" {
return fmt.Errorf("--first and --last are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreatePerson(cmd.Context(), first, last, companyID, jobTitle, about)
if err != nil {
return err
}
pid := strconv.Itoa(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&first, "first", "", "first name")
cmd.Flags().StringVar(&last, "last", "", "last name")
cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id")
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(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
return cmd
}
func companiesCreateCmd() *cobra.Command {
var name, email, website string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a company",
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" {
return fmt.Errorf("--name is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCompany(cmd.Context(), name)
if err != nil {
return err
}
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
}
printObject(out)
return nil
},
}
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)")
return cmd
}
func companiesDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate companies by normalized name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanies(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func companiesDedupePersonsCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-persons",
Short: "Merge duplicate persons under each company",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanyPersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func personsDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate persons by normalized first+last name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupePersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func contactsDedupeInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-info",
Short: "Remove duplicate contact info rows (email, phone, …)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeContactInfo(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var crmCmd = &cobra.Command{
Use: "crm",
Short: "CRM maintenance (dedupe, cleanup)",
}
func init() {
rootCmd.AddCommand(crmCmd)
crmCmd.AddCommand(crmCleanupCmd())
}
func crmCleanupCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Run all CRM dedupe passes (companies, persons, associations, titles)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := onlyoffice.CleanupCRM(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group deals by position only (strip ' @ Company')")
return cmd
}
func dedupeRunE(fn func(cmd *cobra.Command, c *onlyoffice.Client) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
return fn(cmd, c)
}
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var crmTasksCmd = &cobra.Command{
Use: "crm-tasks",
Aliases: []string{"crm-task"},
Short: "CRM tasks (standalone, not project tasks)",
}
func init() {
rootCmd.AddCommand(crmTasksCmd)
crmTasksCmd.AddCommand(crmTasksListCmd())
crmTasksCmd.AddCommand(crmTasksCreateCmd())
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
}
func crmTasksListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListCRMTasks(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func crmTasksCreateCmd() *cobra.Command {
var deadline, desc, entityType string
var categoryID, contactID, entityID int
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a CRM task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCRMTask(cmd.Context(), args[0], deadline, categoryID, contactID, entityType, entityID, desc)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
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")
cmd.Flags().IntVar(&entityID, "entity-id", 0, "parent entity id")
cmd.Flags().StringVar(&desc, "description", "", "description")
return cmd
}
func crmTasksDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]",
Aliases: []string{"rm"},
Short: "Delete CRM tasks",
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 := strconv.Atoi(id); err != nil {
return fmt.Errorf("task id %q must be integer: %w", id, err)
}
out, err := c.DeleteCRMTask(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func crmTasksCategoriesCmd() *cobra.Command {
return &cobra.Command{
Use: "categories",
Short: "List CRM task categories",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListTaskCategories(cmd.Context())
if err != nil {
return err
}
printTable([]string{"id", "title", "sortOrder", "imagePath"}, out)
return nil
},
}
}
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var mailsCmd = &cobra.Command{
Use: "mails",
Aliases: []string{"mail"},
Short: "OnlyOffice Workspace mail — list, read, delete",
}
func init() {
rootCmd.AddCommand(mailsCmd)
mailsCmd.AddCommand(mailsAccountsCmd())
mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd())
mailsCmd.AddCommand(mailsDeleteCmd())
}
func mailsAccountsCmd() *cobra.Command {
return &cobra.Command{
Use: "accounts",
Short: "List mailboxes linked to your OnlyOffice account",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
accounts, err := c.ListMailAccounts(cmd.Context())
if err != nil {
return err
}
if outputFormat == "json" {
printObject(accounts)
return nil
}
printTable([]string{"mailboxId", "email", "enabled", "isDefault"}, onlyoffice.MailAccountsAsTableRows(accounts))
return nil
},
}
}
func mailsFoldersCmd() *cobra.Command {
return &cobra.Command{
Use: "folders",
Short: "List mail folders (inbox, sent, trash, …)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
folders, err := c.ListMailFolders(cmd.Context())
if err != nil {
return err
}
if outputFormat == "json" {
printObject(folders)
return nil
}
printTable([]string{"id", "unread", "total_count", "time_modified"}, onlyoffice.MailFoldersAsTableRows(folders))
return nil
},
}
}
func mailsListCmd() *cobra.Command {
var folder string
var limit, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List messages in a folder (default inbox)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
folderID, err := onlyoffice.ResolveMailFolder(folder)
if err != nil {
return err
}
msgs, err := c.ListMailMessages(cmd.Context(), onlyoffice.MailMessagesFilter{
Folder: folderID,
Count: limit,
StartIndex: offset,
})
if err != nil {
return err
}
if outputFormat == "json" {
printObject(msgs)
return nil
}
printTable([]string{"id", "subject", "fromName", "fromAddress", "date", "folder", "size", "isNew"}, onlyoffice.MailMessagesAsTableRows(msgs))
return nil
},
}
cmd.Flags().StringVarP(&folder, "folder", "f", "inbox", "folder name (inbox|sent|drafts|trash|spam) or numeric id")
cmd.Flags().IntVar(&limit, "limit", 50, "max messages to return (paginates past API page size of 25)")
cmd.Flags().IntVar(&offset, "offset", 0, "skip this many messages before listing")
return cmd
}
func mailsGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get MESSAGE_ID",
Short: "Read one message by numeric id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetMailMessage(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete ID [ID...]",
Aliases: []string{"rm"},
Short: "Remove messages from the mailbox (moves to trash or deletes permanently per server rules)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ids := make([]int, 0, len(args))
for _, a := range args {
id, err := strconv.Atoi(strings.TrimSpace(a))
if err != nil {
return err
}
ids = append(ids, id)
}
out, err := c.RemoveMailMessages(cmd.Context(), ids...)
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
+36
View File
@@ -0,0 +1,36 @@
// Command oo is a thin CLI over the github.com/eslider/go-onlyoffice library.
//
// 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 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 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
//
// Every list supports `--output/-o json|table` (table is the default).
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/oo@latest
package main
import (
"fmt"
"os"
)
func main() {
if err := execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+229
View File
@@ -0,0 +1,229 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var opportunitiesCmd = &cobra.Command{
Use: "opportunities",
Aliases: []string{"deals", "deal"},
Short: "CRM opportunities (a.k.a. deals)",
}
func init() {
rootCmd.AddCommand(opportunitiesCmd)
opportunitiesCmd.AddCommand(oppListCmd())
opportunitiesCmd.AddCommand(oppGetCmd())
opportunitiesCmd.AddCommand(oppCreateCmd())
opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd())
opportunitiesCmd.AddCommand(oppDedupeCmd())
opportunitiesCmd.AddCommand(oppDedupeMembersCmd())
opportunitiesCmd.AddCommand(oppFixTitlesCmd())
}
func oppListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List opportunities",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListOpportunities(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
// Flatten nested bidCurrency {abbreviation, ...} for readability.
for _, row := range list {
if cur, ok := row["bidCurrency"].(map[string]any); ok {
row["bidCurrency"] = cur["abbreviation"]
}
}
}
printTable([]string{"id", "title", "bidValue", "bidCurrency", "stageName"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 100, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func oppGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get OPPORTUNITY_ID",
Short: "Show an opportunity by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetOpportunity(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func oppCreateCmd() *cobra.Command {
var stage int
var bid float64
var currency, responsible, desc string
var contacts []string
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create an opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, responsible, currency, desc, bid)
if err != nil {
return err
}
did := strconv.Itoa(int(flexIDFloat(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printObject(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "pipeline stage id")
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
cmd.Flags().StringVar(&currency, "currency", "EUR", "bid currency")
cmd.Flags().StringVar(&responsible, "responsible", "", "responsible user id")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id to add as member (repeatable)")
return cmd
}
func oppDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more opportunities",
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 {
out, err := c.DeleteOpportunity(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func oppStagesCmd() *cobra.Command {
return &cobra.Command{
Use: "stages",
Short: "List pipeline stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printTable([]string{"id", "title", "sortOrder", "color"}, out)
return nil
},
}
}
func oppMemberAddCmd() *cobra.Command {
return &cobra.Command{
Use: "member-add OPPORTUNITY_ID CONTACT_ID",
Short: "Attach a contact to an opportunity",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddOpportunityMember(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func oppDedupeCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate opportunities by title",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunities(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group by position only")
return cmd
}
func oppDedupeMembersCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-members",
Short: "Remove duplicate contacts on each deal (by id and display name)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunityMembers(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func oppFixTitlesCmd() *cobra.Command {
return &cobra.Command{
Use: "fix-titles",
Short: "Fix malformed deal titles (leading @, spacing)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.FixOpportunityTitles(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var projectsCmd = &cobra.Command{
Use: "projects",
Aliases: []string{"prj"},
Short: "OnlyOffice Projects",
}
func init() {
rootCmd.AddCommand(projectsCmd)
projectsCmd.AddCommand(prjListCmd())
projectsCmd.AddCommand(prjGetCmd())
projectsCmd.AddCommand(prjMilestonesCmd())
projectsCmd.AddCommand(prjCreateCmd())
projectsCmd.AddCommand(prjUpdateCmd())
projectsCmd.AddCommand(prjDeleteCmd())
}
func prjListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all projects",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.GetProjects()
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(list))
for _, p := range list {
row := map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
"status": derefInt(p.Status),
}
if p.TaskCount != nil {
row["tasks"] = *p.TaskCount
}
if p.IsPrivate != nil {
row["private"] = *p.IsPrivate
}
rows = append(rows, row)
}
printTable([]string{"id", "title", "status", "tasks", "private"}, rows)
return nil
},
}
}
func prjGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get [PROJECT_ID]",
Short: "Show a single project (default: $OO_PROJECT_ID)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id := ""
if len(args) == 1 {
id = args[0]
}
out, err := c.GetProjectByID(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func prjMilestonesCmd() *cobra.Command {
return &cobra.Command{
Use: "milestones PROJECT_ID",
Short: "List milestones of a 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 must be integer: %w", err)
}
ms, err := c.GetProjectMilestones(&onlyoffice.Project{ID: &pid})
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(ms))
for _, m := range ms {
rows = append(rows, map[string]any{
"id": derefInt64(m.ID),
"title": derefString(m.Title),
"status": derefInt64(m.Status),
})
}
printTable([]string{"id", "title", "status"}, rows)
return nil
},
}
}
func prjCreateCmd() *cobra.Command {
var desc, resp string
cmd := &cobra.Command{
Use: "create TITLE",
Short: "Create a new project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
Title: args[0],
Description: desc,
ResponsibleID: resp,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
})
return nil
},
}
cmd.Flags().StringVar(&desc, "description", "", "project description")
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
return cmd
}
func prjUpdateCmd() *cobra.Command {
var title, desc, resp string
cmd := &cobra.Command{
Use: "update PROJECT_ID",
Short: "Update project fields (only non-empty)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: desc,
ResponsibleID: resp,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
})
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "new title")
cmd.Flags().StringVar(&desc, "description", "", "new description")
cmd.Flags().StringVar(&resp, "responsible", "", "new responsible user id")
return cmd
}
func prjDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete PROJECT_ID [PROJECT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more projects",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, raw := range args {
id, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("project id %q must be integer: %w", raw, err)
}
p, err := c.DeleteProject(id)
if err != nil {
return err
}
printObject(map[string]any{"id": derefInt(p.ID), "title": p.String()})
}
return nil
},
}
}
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}
func derefInt64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"fmt"
"os"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
func init() {
projectsCmd.AddCommand(projectFilesCmd())
}
func projectFilesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "files",
Short: "Project Documents folder: list, upload, download, rename, delete",
}
cmd.AddCommand(prjFilesListCmd())
cmd.AddCommand(prjFilesUploadCmd())
cmd.AddCommand(prjFilesDownloadCmd())
cmd.AddCommand(prjFilesRenameCmd())
cmd.AddCommand(prjFilesDeleteCmd())
return cmd
}
func prjFilesListCmd() *cobra.Command {
var showFolders bool
cmd := &cobra.Command{
Use: "list PROJECT_ID",
Short: "List files (and optionally folders) attached to the project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pf, err := c.GetProjectFiles(cmd.Context(), args[0])
if err != nil {
return err
}
if showFolders && len(pf.Folders) > 0 {
frows := make([]map[string]any, 0, len(pf.Folders))
for _, f := range pf.Folders {
if f == nil {
continue
}
frows = append(frows, map[string]any{
"id": folderIDStr(f),
"title": derefString(f.Title),
"filesCount": derefInt(f.FilesCount),
"foldersCount": derefInt(f.FoldersCount),
})
}
if outputFormat == "table" {
fmt.Println("folders:")
}
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
}
rows := fileEntryRows(pf.Files)
if outputFormat == "table" {
fmt.Println("files:")
}
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, rows)
return nil
},
}
cmd.Flags().BoolVar(&showFolders, "folders", false, "also print project subfolders")
return cmd
}
func prjFilesUploadCmd() *cobra.Command {
return &cobra.Command{
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload file(s) into the project's Documents folder",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid := args[0]
for _, p := range args[1:] {
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
}
return nil
},
}
}
func prjFilesDownloadCmd() *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "download FILE_ID",
Short: "Download file bytes via viewUrl (default path: ./<title>)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
f, err := c.GetFile(cmd.Context(), args[0])
if err != nil {
return err
}
path := to
if path == "" {
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
n, err := c.DownloadFile(cmd.Context(), args[0], out)
if err != nil {
_ = os.Remove(path)
return err
}
if outputFormat == "json" {
printObject(map[string]any{"path": path, "bytes": n})
return nil
}
fmt.Printf("downloaded: %s (%d bytes)\n", path, n)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
return cmd
}
func prjFilesRenameCmd() *cobra.Command {
return &cobra.Command{
Use: "rename FILE_ID NEW_TITLE",
Short: "Rename a file (include extension in NEW_TITLE)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
return nil
},
}
}
func prjFilesDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete FILE_ID [FILE_ID...]",
Aliases: []string{"rm"},
Short: "Permanently delete file(s) from Documents",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ids := make([]int, 0, len(args))
for _, s := range args {
id, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("file id %q: %w", s, err)
}
ids = append(ids, id)
}
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
return err
}
printObject(map[string]any{"deleted": ids})
return nil
},
}
}
func fileEntryRows(files []*onlyoffice.FileEntry) []map[string]any {
rows := make([]map[string]any, 0, len(files))
for _, f := range files {
if f == nil {
continue
}
rows = append(rows, fileEntryToMap(f))
}
return rows
}
func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
m := map[string]any{
"id": fileIDStr(f),
"title": onlyoffice.FileEntryTitle(f),
"fileExst": derefString(f.FileExst),
"contentLength": derefString(f.ContentLength),
}
if f.Updated != nil {
m["updated"] = f.Updated.Format(time.RFC3339)
}
return m
}
func fileIDStr(f *onlyoffice.FileEntry) string {
if f == nil || f.ID == nil {
return ""
}
return f.ID.String()
}
func folderIDStr(f *onlyoffice.FolderEntry) string {
if f == nil || f.ID == nil {
return ""
}
return f.ID.String()
}
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var tasksCmd = &cobra.Command{
Use: "tasks",
Aliases: []string{"task"},
Short: "Project tasks and subtasks",
}
func init() {
rootCmd.AddCommand(tasksCmd)
tasksCmd.AddCommand(taskListCmd())
tasksCmd.AddCommand(taskGetCmd())
tasksCmd.AddCommand(taskCreateCmd())
tasksCmd.AddCommand(taskUpdateCmd())
tasksCmd.AddCommand(taskDeleteCmd())
tasksCmd.AddCommand(subtaskCmd())
}
func taskListCmd() *cobra.Command {
var project, status string
var all, verbose bool
cmd := &cobra.Command{
Use: "list",
Short: "List project tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
var tasks []map[string]any
if all {
tasks, err = c.ListAllTasks(cmd.Context(), status)
} else {
tasks, err = c.ListTasks(cmd.Context(), project, status)
}
if err != nil {
return err
}
if verbose {
for i, t := range tasks {
d, err := c.GetTaskByID(cmd.Context(), idString(t, "id"))
if err == nil {
tasks[i] = d
}
}
}
printTable([]string{"id", "title", "status", "priority", "responsible"}, tasks)
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "include subtasks & full details")
return cmd
}
func taskGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get TASK_ID",
Short: "Show a single task (incl. subtasks)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetTaskByID(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func taskCreateCmd() *cobra.Command {
var project, desc, deadline, prio string
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a project task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
p := 0
switch prio {
case "high":
p = 1
case "low":
p = -1
}
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
if err != nil {
return err
}
printObject(out)
return nil
},
}
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(&prio, "priority", "normal", "high|normal|low")
return cmd
}
func taskUpdateCmd() *cobra.Command {
var status string
cmd := &cobra.Command{
Use: "update TASK_ID",
Short: "Update task status",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if status == "" {
return fmt.Errorf("--status is required (open|closed)")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.UpdateTaskStatus(cmd.Context(), args[0], status)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVarP(&status, "status", "s", "", "new status: open|closed")
return cmd
}
func taskDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more tasks",
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 {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
// subtaskCmd exposes `oo tasks subtask {add}` — keeps things tidy without a
// dedicated subtasks top-level command.
func subtaskCmd() *cobra.Command {
sub := &cobra.Command{
Use: "subtask",
Short: "Subtasks of a parent task",
}
sub.AddCommand(&cobra.Command{
Use: "add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add one or more subtasks",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
parent := args[0]
for _, title := range args[1:] {
out, err := c.AddSubtask(cmd.Context(), parent, title)
if err != nil {
return err
}
printObject(out)
}
return nil
},
})
return sub
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
func init() {
tasksCmd.AddCommand(taskFilesCmd())
}
func taskFilesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "files",
Short: "Task attachments: list, upload (project folder + attach), detach",
}
cmd.AddCommand(taskFilesListCmd())
cmd.AddCommand(taskFilesUploadCmd())
cmd.AddCommand(taskFilesDetachCmd())
return cmd
}
func taskFilesListCmd() *cobra.Command {
return &cobra.Command{
Use: "list TASK_ID",
Short: "List files attached to a task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.GetTaskFiles(cmd.Context(), args[0])
if err != nil {
return err
}
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, fileEntryRows(list))
return nil
},
}
}
func taskFilesUploadCmd() *cobra.Command {
return &cobra.Command{
Use: "upload TASK_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload into the task's project folder and attach each file to the task",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
tid := args[0]
for _, p := range args[1:] {
entry, err := c.UploadTaskFile(cmd.Context(), tid, p)
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
}
return nil
},
}
}
func taskFilesDetachCmd() *cobra.Command {
return &cobra.Command{
Use: "detach TASK_ID FILE_ID [FILE_ID...]",
Short: "Detach file(s) from the task (files remain in Documents)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
tid := args[0]
for _, fid := range args[1:] {
if _, err := strconv.Atoi(fid); err != nil {
return fmt.Errorf("file id %q: %w", fid, err)
}
if err := c.DetachTaskFile(cmd.Context(), tid, fid); err != nil {
return err
}
printObject(map[string]any{"taskId": tid, "detachedFileId": fid})
}
return nil
},
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"github.com/spf13/cobra"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Portal / workspace users",
}
func init() {
rootCmd.AddCommand(usersCmd)
usersCmd.AddCommand(usersListCmd())
usersCmd.AddCommand(usersSelfCmd())
rootCmd.AddCommand(whoamiCmd())
}
func usersListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List portal users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
users, err := c.GetUsers()
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(users))
for _, u := range users {
rows = append(rows, map[string]any{
"id": derefString(u.ID),
"userName": derefString(u.UserName),
"displayName": derefString(u.DisplayName),
"email": derefString(u.Email),
"isAdmin": derefBool(u.IsAdmin),
"status": derefInt(u.Status),
})
}
printTable([]string{"id", "userName", "displayName", "email", "isAdmin", "status"}, rows)
return nil
},
}
}
func usersSelfCmd() *cobra.Command {
return &cobra.Command{
Use: "self",
Short: "Show current user id (people/@self)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
printObject(map[string]any{"id": id})
return nil
},
}
}
// whoamiCmd is a convenience shortcut at the root level.
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Alias for `oo users self`",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
printObject(map[string]any{"id": id})
return nil
},
}
}
func derefBool(p *bool) bool {
if p == nil {
return false
}
return *p
}
+8
View File
@@ -0,0 +1,8 @@
package main
// Build metadata injected at release time via -ldflags (GoReleaser).
var (
version = "dev"
commit = "none"
date = "unknown"
)
+265 -186
View File
@@ -2,7 +2,7 @@ package onlyoffice
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes. // Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
// These expose untyped maps for flexibility — they are primarily consumed by // These expose untyped maps for flexibility — they are primarily consumed by
// cmd/oo-cli and the applications-sync workflow. // cmd/oo and the applications-sync workflow.
import ( import (
"context" "context"
@@ -16,8 +16,8 @@ import (
// ListContacts returns a page of CRM contacts and the total count. // ListContacts returns a page of CRM contacts and the total count.
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) { func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
q := url.Values{} q := url.Values{}
q.Set("count", fmt.Sprintf("%d", count)) q.Set("count", strconv.Itoa(count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex)) q.Set("startIndex", strconv.Itoa(startIndex))
if search != "" { if search != "" {
q.Set("filterValue", search) q.Set("filterValue", search)
} }
@@ -41,40 +41,41 @@ func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search
// GetContact returns a single contact by id. // GetContact returns a single contact by id.
func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) { func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID))) return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// FindCompany searches for a company contact with an exact (case-insensitive) // FindCompany searches for a company contact with an exact (case-insensitive)
// displayName match. Returns nil when not found. // displayName match. Returns nil when not found.
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) { func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, name) needle := CompanyGroupingKey(name)
const page = 50
for start := 0; ; start += page {
items, total, err := c.ListContacts(ctx, page, start, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
needle := strings.ToLower(strings.TrimSpace(name))
for _, co := range items { for _, co := range items {
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle { if !isCompany(co) {
continue
}
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
return co, nil return co, nil
} }
} }
if start+page >= total || len(items) == 0 {
break
}
}
return nil, nil return nil, nil
} }
// FindPerson searches for a person by first+last (case-insensitive). // FindPerson searches for a person by first+last (case-insensitive).
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) { func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last) firstNeedle := strings.ToLower(strings.TrimSpace(first))
lastNeedle := strings.ToLower(strings.TrimSpace(last))
const page = 50
for start := 0; ; start += page {
items, total, err := c.ListContacts(ctx, page, start, first+" "+last)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -82,12 +83,15 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string
if isCompany(p) { if isCompany(p) {
continue continue
} }
fn := strings.ToLower(fmt.Sprint(p["firstName"])) if strings.ToLower(fmt.Sprint(p["firstName"])) == firstNeedle &&
ln := strings.ToLower(fmt.Sprint(p["lastName"])) strings.ToLower(fmt.Sprint(p["lastName"])) == lastNeedle {
if fn == strings.ToLower(first) && ln == strings.ToLower(last) {
return p, nil return p, nil
} }
} }
if start+page >= total || len(items) == 0 {
break
}
}
return nil, nil return nil, nil
} }
@@ -100,19 +104,7 @@ func isCompany(m map[string]any) bool {
func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) { func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) {
fields := url.Values{} fields := url.Values{}
fields.Set("companyName", name) fields.Set("companyName", name)
raw, err := c.postForm(ctx, "/api/2.0/crm/contact/company.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/contact/company.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// CreatePerson creates a person contact; companyID == 0 means unlinked. // CreatePerson creates a person contact; companyID == 0 means unlinked.
@@ -129,19 +121,7 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID
if about != "" { if about != "" {
fields.Set("about", about) fields.Set("about", about)
} }
raw, err := c.postForm(ctx, "/api/2.0/crm/contact/person.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// AddContactInfo attaches an email/website/phone/etc. to a contact. // AddContactInfo attaches an email/website/phone/etc. to a contact.
@@ -153,40 +133,102 @@ func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataVa
fields.Set("infoType", infoType) fields.Set("infoType", infoType)
fields.Set("data", dataValue) fields.Set("data", dataValue)
fields.Set("category", category) fields.Set("category", category)
fields.Set("isPrimary", fmt.Sprintf("%t", isPrimary)) fields.Set("isPrimary", strconv.FormatBool(isPrimary))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields) return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
// DeleteContact removes a CRM contact by id. // DeleteContact removes a CRM contact by id.
func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[string]any, error) { func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
}
// ListAllContacts paginates through every CRM contact.
func (c *Client) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
const page = 100
var all []map[string]any
for start := 0; ; start += page {
chunk, total, err := c.ListContacts(ctx, page, start, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := responseField(raw, "response") all = append(all, chunk...)
if err != nil { if start+page >= total || len(chunk) == 0 {
return nil, err break
} }
var out map[string]any }
_ = json.Unmarshal(resp, &out) return all, nil
}
// MergeContacts merges secondary into primary (secondary is removed).
func (c *Client) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
fields := url.Values{}
fields.Set("fromContactId", secondaryID)
fields.Set("toContactId", primaryID)
out, err := c.putFormObject(ctx, "/api/2.0/crm/contact/merge.json", fields)
if err == nil {
return out, nil return out, nil
} }
// Some instances expect JSON body with alternate field names.
body := map[string]any{
"fromContactId": secondaryID,
"toContactId": primaryID,
}
return c.putJSONObject(ctx, "/api/2.0/crm/contact/merge.json", body)
}
// ListCompanyPersons returns persons linked to a company.
func (c *Client) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s/person.json", url.PathEscape(companyID)))
}
// DeleteContactInfo removes one info row from a contact.
func (c *Client) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data/%s.json", url.PathEscape(contactID), url.PathEscape(dataID)))
}
// ContactInfoRows returns commonData/info rows from a contact map.
func ContactInfoRows(contact map[string]any) []map[string]any {
for _, key := range []string{"commonData", "data", "contactData"} {
if rows, ok := contact[key].([]any); ok {
return mapsFromAnySlice(rows)
}
if rows, ok := contact[key].([]map[string]any); ok {
return rows
}
}
return nil
}
// HasContactInfo reports whether a contact already has the given type+value.
func HasContactInfo(contact map[string]any, infoType, value string) bool {
key := ContactInfoKey(infoType, value)
for _, row := range ContactInfoRows(contact) {
v := fmt.Sprint(row["data"])
if v == "" || v == "<nil>" {
v = fmt.Sprint(row["value"])
}
if ContactInfoKey(fmt.Sprint(row["infoType"]), v) == key {
return true
}
}
return false
}
func mapsFromAnySlice(rows []any) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
if m, ok := row.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// ListOpportunities returns a page of deals/opportunities and the total count. // ListOpportunities returns a page of deals/opportunities and the total count.
func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) { func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{} q := url.Values{}
q.Set("count", fmt.Sprintf("%d", count)) q.Set("count", strconv.Itoa(count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex)) q.Set("startIndex", strconv.Itoa(startIndex))
raw, err := c.getJSON(ctx, "/api/2.0/crm/opportunity/filter.json?"+q.Encode()) raw, err := c.getJSON(ctx, "/api/2.0/crm/opportunity/filter.json?"+q.Encode())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -203,19 +245,7 @@ func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) (
// GetOpportunity returns a single opportunity (deal) by id. // GetOpportunity returns a single opportunity (deal) by id.
func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) { func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id))) return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// CreateOpportunity creates a new deal. An empty responsibleID falls back to // CreateOpportunity creates a new deal. An empty responsibleID falls back to
@@ -237,39 +267,19 @@ func (c *Client) CreateOpportunity(ctx context.Context, title string, stageID in
fields.Set("responsibleId", responsibleID) fields.Set("responsibleId", responsibleID)
fields.Set("bidCurrencyAbbr", bidCurrency) fields.Set("bidCurrencyAbbr", bidCurrency)
if bidValue != 0 { if bidValue != 0 {
fields.Set("bidValue", fmt.Sprintf("%g", bidValue)) fields.Set("bidValue", strconv.FormatFloat(bidValue, 'g', -1, 64))
} }
if description != "" { if description != "" {
fields.Set("description", description) fields.Set("description", description)
} }
raw, err := c.postForm(ctx, "/api/2.0/crm/opportunity.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/opportunity.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// AddOpportunityMember links a contact to an opportunity. // AddOpportunityMember links a contact to an opportunity.
func (c *Client) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) { func (c *Client) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)), url.Values{}) return c.postFormObject(ctx,
if err != nil { fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)),
return nil, err url.Values{})
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
// ListDealStages returns the configured opportunity stages. // ListDealStages returns the configured opportunity stages.
@@ -279,24 +289,155 @@ func (c *Client) ListDealStages(ctx context.Context) ([]map[string]any, error) {
// DeleteOpportunity removes a deal by id. // DeleteOpportunity removes a deal by id.
func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) { func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
}
// ListAllOpportunities paginates through every opportunity.
func (c *Client) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
const page = 100
var all []map[string]any
for start := 0; ; start += page {
chunk, total, err := c.ListOpportunities(ctx, page, start)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := responseField(raw, "response") all = append(all, chunk...)
if start+page >= total || len(chunk) == 0 {
break
}
}
return all, nil
}
// OpportunityMembers extracts the members slice from a GetOpportunity response.
func OpportunityMembers(opp map[string]any) []map[string]any {
raw, ok := opp["members"].([]any)
if !ok {
if rows, ok := opp["members"].([]map[string]any); ok {
return rows
}
return nil
}
return mapsFromAnySlice(raw)
}
// ListOpportunityMembers returns contacts linked to an opportunity.
func (c *Client) ListOpportunityMembers(ctx context.Context, oppID string) ([]map[string]any, error) {
opp, err := c.GetOpportunity(ctx, oppID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out map[string]any if members := OpportunityMembers(opp); len(members) > 0 {
_ = json.Unmarshal(resp, &out) return members, nil
return out, nil }
return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact.json", url.PathEscape(oppID)))
}
// RemoveOpportunityMember detaches a contact from an opportunity.
func (c *Client) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)))
}
// IsOpportunityMember reports whether contactID is already on the opportunity.
func (c *Client) IsOpportunityMember(ctx context.Context, oppID, contactID string) (bool, error) {
members, err := c.ListOpportunityMembers(ctx, oppID)
if err != nil {
return false, err
}
want := flexInt(contactID)
for _, m := range members {
if flexInt(m["id"]) == want {
return true, nil
}
}
return false, nil
}
// UpdateOpportunityTitle renames a deal; loads full record and PUTs it back.
func (c *Client) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
opp, err := c.GetOpportunity(ctx, id)
if err != nil {
return nil, err
}
body := opportunityUpdateBody(opp, newTitle)
return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)), body)
}
func opportunityUpdateBody(opp map[string]any, title string) map[string]any {
body := map[string]any{
"opportunityid": flexInt(opp["id"]),
"title": title,
"description": stringField(opp, "description"),
"isPrivate": boolField(opp, "isPrivate"),
"isNotify": false,
}
if stage, ok := opp["stage"].(map[string]any); ok {
body["stageid"] = flexInt(stage["id"])
}
if resp, ok := opp["responsible"].(map[string]any); ok {
body["responsibleid"] = fmt.Sprint(resp["id"])
}
if cur, ok := opp["bidCurrency"].(map[string]any); ok {
body["bidCurrencyAbbr"] = stringField(cur, "abbreviation")
} else {
body["bidCurrencyAbbr"] = "EUR"
}
body["bidValue"] = floatField(opp, "bidValue")
body["bidType"] = 0
body["perPeriodValue"] = 0
body["successProbability"] = 1
var memberIDs []int64
seen := make(map[int64]bool)
for _, m := range OpportunityMembers(opp) {
id := flexInt(m["id"])
if id == 0 || seen[id] {
continue
}
seen[id] = true
memberIDs = append(memberIDs, id)
}
if len(memberIDs) > 0 {
body["members"] = memberIDs
body["contactid"] = memberIDs[0]
}
if al, ok := opp["accessList"].([]any); ok && len(al) > 0 {
body["accessList"] = al
} else {
body["accessList"] = []any{}
}
return body
}
func stringField(m map[string]any, key string) string {
v := fmt.Sprint(m[key])
if v == "<nil>" {
return ""
}
return v
}
func boolField(m map[string]any, key string) bool {
v, _ := m[key].(bool)
return v
}
func floatField(m map[string]any, key string) float64 {
switch x := m[key].(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
} }
// ListCases returns a page of CRM cases and the total count. // ListCases returns a page of CRM cases and the total count.
func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) { func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{} q := url.Values{}
q.Set("count", fmt.Sprintf("%d", count)) q.Set("count", strconv.Itoa(count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex)) q.Set("startIndex", strconv.Itoa(startIndex))
raw, err := c.getJSON(ctx, "/api/2.0/crm/case/filter.json?"+q.Encode()) raw, err := c.getJSON(ctx, "/api/2.0/crm/case/filter.json?"+q.Encode())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -315,56 +456,26 @@ func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[st
func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) { func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) {
fields := url.Values{} fields := url.Values{}
fields.Set("title", title) fields.Set("title", title)
raw, err := c.postForm(ctx, "/api/2.0/crm/case.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/case.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// AddCaseMember links a contact to a CRM case. // AddCaseMember links a contact to a CRM case.
func (c *Client) AddCaseMember(ctx context.Context, caseID, contactID string) (map[string]any, error) { func (c *Client) AddCaseMember(ctx context.Context, caseID, contactID string) (map[string]any, error) {
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)), url.Values{}) return c.postFormObject(ctx,
if err != nil { fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)),
return nil, err url.Values{})
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
// DeleteCase removes a case by id. // DeleteCase removes a case by id.
func (c *Client) DeleteCase(ctx context.Context, id string) (map[string]any, error) { func (c *Client) DeleteCase(ctx context.Context, id string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
// ListCRMTasks returns a page of CRM tasks (separate from Project tasks). // ListCRMTasks returns a page of CRM tasks (separate from Project tasks).
func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) { func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
q := url.Values{} q := url.Values{}
q.Set("count", fmt.Sprintf("%d", count)) q.Set("count", strconv.Itoa(count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex)) q.Set("startIndex", strconv.Itoa(startIndex))
raw, err := c.getJSON(ctx, "/api/2.0/crm/task/filter.json?"+q.Encode()) raw, err := c.getJSON(ctx, "/api/2.0/crm/task/filter.json?"+q.Encode())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -397,34 +508,12 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
if description != "" { if description != "" {
fields.Set("description", description) fields.Set("description", description)
} }
raw, err := c.postForm(ctx, "/api/2.0/crm/task.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
} }
// DeleteCRMTask removes a CRM task by id. // DeleteCRMTask removes a CRM task by id.
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) { func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
// ListTaskCategories returns CRM task categories. // ListTaskCategories returns CRM task categories.
@@ -447,17 +536,7 @@ func (c *Client) AddHistoryNote(ctx context.Context, entityType string, entityID
fields.Set("entityId", strconv.Itoa(entityID)) fields.Set("entityId", strconv.Itoa(entityID))
fields.Set("content", content) fields.Set("content", content)
fields.Set("categoryId", strconv.Itoa(categoryID)) fields.Set("categoryId", strconv.Itoa(categoryID))
raw, err := c.postForm(ctx, "/api/2.0/crm/history.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/history.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
} }
func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) { func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) {
+272
View File
@@ -0,0 +1,272 @@
package onlyoffice
import (
"context"
"fmt"
"strconv"
)
// DedupeResult summarizes a cleanup pass.
type DedupeResult struct {
Groups int `json:"groups"`
Merged int `json:"merged"`
Deleted int `json:"deleted"`
Renamed int `json:"renamed"`
Removed int `json:"removed"`
Errors []string `json:"errors,omitempty"`
}
func (r *DedupeResult) addErr(err error) {
if err != nil {
r.Errors = append(r.Errors, err.Error())
}
}
type crmDedupeClient interface {
ListAllContacts(ctx context.Context) ([]map[string]any, error)
ListAllOpportunities(ctx context.Context) ([]map[string]any, error)
GetContact(ctx context.Context, contactID string) (map[string]any, error)
GetOpportunity(ctx context.Context, id string) (map[string]any, error)
MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error)
DeleteOpportunity(ctx context.Context, id string) (map[string]any, error)
DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error)
ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error)
AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error)
}
func executeMergePlans(ctx context.Context, client crmDedupeClient, plans []MergePlan, res *DedupeResult) {
for _, plan := range plans {
res.Groups++
for _, sec := range plan.Secondary {
_, err := client.MergeContacts(ctx, strconv.FormatInt(plan.Primary, 10), strconv.FormatInt(sec, 10))
if err != nil {
res.addErr(fmt.Errorf("merge %d into %d: %w", sec, plan.Primary, err))
continue
}
res.Merged++
}
}
}
// DedupeCompanies merges duplicate company contacts by normalized name.
func DedupeCompanies(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
plans := BuildMergePlans(GroupCompaniesByName(items))
executeMergePlans(ctx, client, plans, &res)
return res, nil
}
// DedupePersons merges duplicate person contacts by normalized first+last.
func DedupePersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
plans := BuildMergePlans(GroupPersonsByKey(items))
executeMergePlans(ctx, client, plans, &res)
return res, nil
}
// DedupeCompanyPersons merges same-name persons within each company.
func DedupeCompanyPersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
grouped := GroupCompanyPersons(items)
for companyID, byName := range grouped {
plans := BuildMergePlans(byName)
if len(plans) == 0 {
continue
}
_ = companyID
executeMergePlans(ctx, client, plans, &res)
}
return res, nil
}
// DedupeContactInfo removes duplicate email/phone/etc rows on all contacts.
func DedupeContactInfo(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
for _, row := range items {
cid := strconv.FormatInt(rowID(row), 10)
contact, err := client.GetContact(ctx, cid)
if err != nil {
res.addErr(err)
continue
}
rows := ContactInfoRows(contact)
for _, dataID := range GroupContactInfoRows(rows) {
_, err := client.DeleteContactInfo(ctx, cid, strconv.FormatInt(dataID, 10))
if err != nil {
res.addErr(err)
continue
}
res.Removed++
}
}
return res, nil
}
// DedupeOpportunities merges duplicate deals by title; relinks members first.
func DedupeOpportunities(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
groups := GroupOpportunitiesByTitle(items, ignoreCompanySuffix)
for _, rows := range groups {
if len(rows) < 2 {
continue
}
res.Groups++
ids := make([]int64, len(rows))
for i, row := range rows {
ids[i] = rowID(row)
}
primary := PickCanonicalID(ids)
primaryID := strconv.FormatInt(primary, 10)
for _, row := range rows {
sec := rowID(row)
if sec == primary {
continue
}
secID := strconv.FormatInt(sec, 10)
opp, err := client.GetOpportunity(ctx, secID)
if err != nil {
res.addErr(err)
continue
}
for _, m := range OpportunityMembers(opp) {
mid := strconv.FormatInt(rowID(m), 10)
_, _ = client.AddOpportunityMember(ctx, primaryID, mid)
}
if _, err := client.DeleteOpportunity(ctx, secID); err != nil {
res.addErr(err)
continue
}
res.Deleted++
}
}
return res, nil
}
// DedupeOpportunityMembers removes duplicate members on each deal (by id and displayName).
func DedupeOpportunityMembers(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
for _, row := range items {
oppID := strconv.FormatInt(rowID(row), 10)
opp, err := client.GetOpportunity(ctx, oppID)
if err != nil {
res.addErr(err)
continue
}
members := OpportunityMembers(opp)
if len(members) == 0 {
continue
}
var ids []int64
for _, m := range members {
ids = append(ids, rowID(m))
}
remove := append(DedupeMemberIDs(ids), DedupeMembersByDisplayName(members)...)
seen := make(map[int64]bool)
for _, contactID := range remove {
if contactID == 0 || seen[contactID] {
continue
}
seen[contactID] = true
if _, err := client.RemoveOpportunityMember(ctx, oppID, strconv.FormatInt(contactID, 10)); err != nil {
res.addErr(err)
continue
}
res.Removed++
}
}
return res, nil
}
// FixOpportunityTitles renames deals with malformed titles.
func FixOpportunityTitles(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
for _, row := range items {
old := fmt.Sprint(row["title"])
newTitle := FixDealTitle(old)
if newTitle == old || newTitle == "" {
continue
}
oppID := strconv.FormatInt(rowID(row), 10)
if _, err := client.UpdateOpportunityTitle(ctx, oppID, newTitle); err != nil {
res.addErr(err)
continue
}
res.Renamed++
}
return res, nil
}
// CleanupCRM runs all dedupe passes in dependency order.
func CleanupCRM(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (map[string]DedupeResult, error) {
out := make(map[string]DedupeResult)
steps := []struct {
name string
fn func(context.Context, crmDedupeClient) (DedupeResult, error)
}{
{"companies", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeCompanies(ctx, c)
}},
{"persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupePersons(ctx, c)
}},
{"company-persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeCompanyPersons(ctx, c)
}},
{"contact-info", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeContactInfo(ctx, c)
}},
{"opportunity-members", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeOpportunityMembers(ctx, c)
}},
}
for _, step := range steps {
r, err := step.fn(ctx, client)
out[step.name] = r
if err != nil {
return out, err
}
}
r, err := DedupeOpportunities(ctx, client, ignoreCompanySuffix)
out["opportunities"] = r
if err != nil {
return out, err
}
r, err = FixOpportunityTitles(ctx, client)
out["fix-titles"] = r
if err != nil {
return out, err
}
return out, nil
}
var _ crmDedupeClient = (*Client)(nil)
+243
View File
@@ -0,0 +1,243 @@
package onlyoffice
import (
"fmt"
"sort"
"strings"
)
// PickCanonicalID returns the lowest non-zero id, or 0 when empty.
func PickCanonicalID(ids []int64) int64 {
if len(ids) == 0 {
return 0
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids[0]
}
func rowID(m map[string]any) int64 {
return flexInt(m["id"])
}
func rowDisplayName(m map[string]any) string {
if v := strings.TrimSpace(fmt.Sprint(m["displayName"])); v != "" && v != "<nil>" {
return v
}
if v := strings.TrimSpace(fmt.Sprint(m["companyName"])); v != "" && v != "<nil>" {
return v
}
first := strings.TrimSpace(fmt.Sprint(m["firstName"]))
last := strings.TrimSpace(fmt.Sprint(m["lastName"]))
return strings.TrimSpace(first + " " + last)
}
// GroupCompaniesByName buckets company contacts by normalized display name.
func GroupCompaniesByName(items []map[string]any) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
if !isCompany(row) {
continue
}
key := CompanyGroupingKey(rowDisplayName(row))
if key == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// GroupPersonsByKey buckets person contacts by normalized first+last.
func GroupPersonsByKey(items []map[string]any) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
if isCompany(row) {
continue
}
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
if strings.TrimSpace(key) == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// DealTitleKey returns the grouping key for an opportunity title.
func DealTitleKey(title string, ignoreCompanySuffix bool) string {
title = strings.TrimSpace(title)
if ignoreCompanySuffix {
return NormalizeOpportunityTitle(StripCompanySuffix(title))
}
if i := strings.LastIndex(title, " @ "); i >= 0 {
pos := strings.TrimSpace(title[:i])
co := StripSloganSuffix(strings.TrimSpace(title[i+len(" @ "):]))
if pos == "" && co != "" {
return NormalizeCompanyName(co)
}
if pos != "" && co != "" {
return collapseKey(pos + " @ " + co)
}
}
return NormalizeCompanyName(StripSloganSuffix(title))
}
// GroupOpportunitiesByTitle buckets deals by title key.
func GroupOpportunitiesByTitle(items []map[string]any, ignoreCompanySuffix bool) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
title := fmt.Sprint(row["title"])
key := DealTitleKey(title, ignoreCompanySuffix)
if key == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// MergePlan lists secondary ids to merge into primary.
type MergePlan struct {
Primary int64
Secondary []int64
}
// BuildMergePlans creates merge plans from duplicate groups (lowest id wins).
func BuildMergePlans(groups map[string][]map[string]any) []MergePlan {
var plans []MergePlan
for _, rows := range groups {
if len(rows) < 2 {
continue
}
ids := make([]int64, len(rows))
for i, row := range rows {
ids[i] = rowID(row)
}
primary := PickCanonicalID(ids)
var secondary []int64
for _, id := range ids {
if id != primary {
secondary = append(secondary, id)
}
}
plans = append(plans, MergePlan{Primary: primary, Secondary: secondary})
}
return plans
}
// DedupeMemberIDs returns duplicate member ids to remove (keep first occurrence).
func DedupeMemberIDs(ids []int64) []int64 {
seen := make(map[int64]int)
var remove []int64
for _, id := range ids {
seen[id]++
if seen[id] > 1 {
remove = append(remove, id)
}
}
return remove
}
// DedupeMembersByDisplayName returns member ids to remove when the same
// displayName appears with different ids (keep lowest id per name).
func DedupeMembersByDisplayName(members []map[string]any) []int64 {
type slot struct {
id int64
}
byName := make(map[string][]slot)
for _, m := range members {
key := MemberDisplayKey(rowDisplayName(m))
if key == "" {
continue
}
byName[key] = append(byName[key], slot{id: rowID(m)})
}
var remove []int64
for _, slots := range byName {
if len(slots) < 2 {
continue
}
ids := make([]int64, len(slots))
for i, s := range slots {
ids[i] = s.id
}
keep := PickCanonicalID(ids)
for _, s := range slots {
if s.id != keep {
remove = append(remove, s.id)
}
}
}
return remove
}
// GroupContactInfoRows returns info row ids to delete (duplicates by type+value).
func GroupContactInfoRows(rows []map[string]any) []int64 {
type rowSlot struct {
id int64
isPrimary bool
}
byKey := make(map[string][]rowSlot)
for _, row := range rows {
infoType := fmt.Sprint(row["infoType"])
value := fmt.Sprint(row["data"])
if value == "" || value == "<nil>" {
value = fmt.Sprint(row["value"])
}
key := ContactInfoKey(infoType, value)
if key == "|" || value == "" || value == "<nil>" {
continue
}
primary, _ := row["isPrimary"].(bool)
byKey[key] = append(byKey[key], rowSlot{id: rowID(row), isPrimary: primary})
}
var remove []int64
for _, slots := range byKey {
if len(slots) < 2 {
continue
}
keep := int64(0)
for _, s := range slots {
if s.isPrimary {
keep = s.id
break
}
}
if keep == 0 {
ids := make([]int64, len(slots))
for i, s := range slots {
ids[i] = s.id
}
keep = PickCanonicalID(ids)
}
for _, s := range slots {
if s.id != keep {
remove = append(remove, s.id)
}
}
}
return remove
}
// GroupCompanyPersons groups persons by company id and person name key.
func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[string]any {
out := make(map[int64]map[string][]map[string]any)
for _, row := range persons {
if isCompany(row) {
continue
}
cid := flexInt(row["companyId"])
if cid == 0 {
continue
}
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
if strings.TrimSpace(key) == "" {
continue
}
if out[cid] == nil {
out[cid] = make(map[string][]map[string]any)
}
out[cid][key] = append(out[cid][key], row)
}
return out
}
+100
View File
@@ -0,0 +1,100 @@
package onlyoffice
import (
"reflect"
"testing"
)
func TestPickCanonicalID(t *testing.T) {
if got := PickCanonicalID([]int64{908, 857, 900}); got != 857 {
t.Fatalf("got %d", got)
}
if got := PickCanonicalID(nil); got != 0 {
t.Fatalf("got %d", got)
}
}
func TestGroupCompaniesByName(t *testing.T) {
items := []map[string]any{
{"id": float64(857), "displayName": "contoso", "isCompany": true},
{"id": float64(908), "displayName": "contoso", "isCompany": true},
{"id": float64(1), "displayName": "Acme", "isCompany": true},
}
groups := GroupCompaniesByName(items)
if len(groups) != 2 {
t.Fatalf("groups: %d", len(groups))
}
key := CompanyGroupingKey("contoso")
if len(groups[key]) != 2 {
t.Fatalf("contoso group: %d", len(groups[key]))
}
}
func TestDedupeMembersByDisplayName(t *testing.T) {
members := []map[string]any{
{"id": float64(857), "displayName": "contoso"},
{"id": float64(908), "displayName": "contoso"},
{"id": float64(10), "displayName": "Acme"},
}
remove := DedupeMembersByDisplayName(members)
want := []int64{908}
if !reflect.DeepEqual(remove, want) {
t.Fatalf("remove %v want %v", remove, want)
}
}
func TestDedupeMemberIDs(t *testing.T) {
remove := DedupeMemberIDs([]int64{1, 2, 2, 3, 1})
if !reflect.DeepEqual(remove, []int64{2, 1}) {
t.Fatalf("got %v", remove)
}
}
func TestGroupContactInfoRows(t *testing.T) {
rows := []map[string]any{
{"id": float64(1), "infoType": "Email", "data": "a@b.com", "isPrimary": false},
{"id": float64(2), "infoType": "Email", "data": "a@b.com", "isPrimary": true},
}
remove := GroupContactInfoRows(rows)
if len(remove) != 1 || remove[0] != 1 {
t.Fatalf("remove %v", remove)
}
}
func TestGroupCompaniesBySlogan(t *testing.T) {
items := []map[string]any{
{"id": float64(1), "displayName": "Affirm", "isCompany": true},
{"id": float64(2), "displayName": "Affirm — Fraud Engineering", "isCompany": true},
}
groups := GroupCompaniesByName(items)
if len(groups) != 1 {
t.Fatalf("groups: %d", len(groups))
}
key := CompanyGroupingKey("Affirm")
if len(groups[key]) != 2 {
t.Fatalf("affirm group: %d", len(groups[key]))
}
}
func TestDedupeMembersBySloganDisplayName(t *testing.T) {
members := []map[string]any{
{"id": float64(1), "displayName": "Affirm"},
{"id": float64(2), "displayName": "Affirm — Fraud Engineering"},
}
remove := DedupeMembersByDisplayName(members)
if !reflect.DeepEqual(remove, []int64{2}) {
t.Fatalf("remove %v", remove)
}
}
func TestDealTitleKey(t *testing.T) {
if got := DealTitleKey("Dev @ Acme", false); got != collapseKey("Dev @ Acme") {
t.Fatalf("got %q", got)
}
if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") {
t.Fatalf("got %q", got)
}
if got := DealTitleKey("Dev @ Affirm — Fraud Engineering", false); got != DealTitleKey("Dev @ Affirm", false) {
t.Fatalf("slogan keys differ: %q vs %q", got, DealTitleKey("Dev @ Affirm", false))
}
}
+133
View File
@@ -0,0 +1,133 @@
package onlyoffice
import (
"context"
"reflect"
"testing"
)
type fakeDedupeClient struct {
contacts []map[string]any
opportunities []map[string]any
contactByID map[string]map[string]any
oppByID map[string]map[string]any
merged [][2]string
deletedOpp []string
deletedInfo [][2]string
removedMember [][2]string
renamed map[string]string
}
func (f *fakeDedupeClient) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
return f.contacts, nil
}
func (f *fakeDedupeClient) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
return f.opportunities, nil
}
func (f *fakeDedupeClient) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
return f.contactByID[contactID], nil
}
func (f *fakeDedupeClient) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
return f.oppByID[id], nil
}
func (f *fakeDedupeClient) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
f.merged = append(f.merged, [2]string{primaryID, secondaryID})
return map[string]any{"id": primaryID}, nil
}
func (f *fakeDedupeClient) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
f.deletedOpp = append(f.deletedOpp, id)
return map[string]any{}, nil
}
func (f *fakeDedupeClient) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
f.deletedInfo = append(f.deletedInfo, [2]string{contactID, dataID})
return map[string]any{}, nil
}
func (f *fakeDedupeClient) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeDedupeClient) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
return map[string]any{}, nil
}
func (f *fakeDedupeClient) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
f.removedMember = append(f.removedMember, [2]string{oppID, contactID})
return map[string]any{}, nil
}
func (f *fakeDedupeClient) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
if f.renamed == nil {
f.renamed = make(map[string]string)
}
f.renamed[id] = newTitle
return map[string]any{"title": newTitle}, nil
}
func TestDedupeCompaniesOrchestration(t *testing.T) {
f := &fakeDedupeClient{
contacts: []map[string]any{
{"id": float64(857), "displayName": "contoso", "isCompany": true},
{"id": float64(908), "displayName": "contoso", "isCompany": true},
},
}
res, err := DedupeCompanies(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Merged != 1 || res.Groups != 1 {
t.Fatalf("res %+v", res)
}
want := [][2]string{{"857", "908"}}
if !reflect.DeepEqual(f.merged, want) {
t.Fatalf("merged %v", f.merged)
}
}
func TestDedupeOpportunityMembersOrchestration(t *testing.T) {
f := &fakeDedupeClient{
opportunities: []map[string]any{{"id": float64(231), "title": " @ contoso"}},
oppByID: map[string]map[string]any{
"231": {
"id": float64(231),
"members": []any{
map[string]any{"id": float64(857), "displayName": "contoso"},
map[string]any{"id": float64(908), "displayName": "contoso"},
},
},
},
}
res, err := DedupeOpportunityMembers(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Removed != 1 {
t.Fatalf("res %+v", res)
}
if len(f.removedMember) != 1 || f.removedMember[0][1] != "908" {
t.Fatalf("removed %v", f.removedMember)
}
}
func TestFixOpportunityTitlesOrchestration(t *testing.T) {
f := &fakeDedupeClient{
opportunities: []map[string]any{{"id": float64(231), "title": " @ contoso"}},
oppByID: map[string]map[string]any{"231": {"id": float64(231), "title": " @ contoso"}},
}
res, err := FixOpportunityTitles(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Renamed != 1 {
t.Fatalf("res %+v", res)
}
if f.renamed["231"] != "contoso" {
t.Fatalf("renamed %v", f.renamed)
}
}
+129
View File
@@ -0,0 +1,129 @@
//go:build integration
package onlyoffice
import (
"context"
"fmt"
"strconv"
"strings"
"testing"
"time"
)
const testCRMPrefix = "go-onlyoffice-test-"
func TestIntegrationMergeContacts(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
name := testCRMPrefix + "merge-" + suffix
a, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany a: %v", err)
}
b, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany b: %v", err)
}
aID := strconv.FormatInt(flexInt(a["id"]), 10)
bID := strconv.FormatInt(flexInt(b["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteContact(ctx, aID) })
if _, err := c.MergeContacts(ctx, aID, bID); err != nil {
t.Fatalf("MergeContacts: %v", err)
}
got, err := c.GetContact(ctx, aID)
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if got == nil {
t.Fatal("primary contact missing after merge")
}
}
func TestIntegrationUpdateOpportunityTitle(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
stages, err := c.ListDealStages(ctx)
if err != nil || len(stages) == 0 {
t.Fatalf("ListDealStages: %v", err)
}
stageID := int(flexInt(stages[0]["id"]))
title := testCRMPrefix + "title-" + strconv.FormatInt(time.Now().UnixNano(), 10)
opp, err := c.CreateOpportunity(ctx, title, stageID, "", "EUR", "", 0)
if err != nil {
t.Fatalf("CreateOpportunity: %v", err)
}
id := strconv.FormatInt(flexInt(opp["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
newTitle := title + "-renamed"
if _, err := c.UpdateOpportunityTitle(ctx, id, newTitle); err != nil {
t.Fatalf("UpdateOpportunityTitle: %v", err)
}
got, err := c.GetOpportunity(ctx, id)
if err != nil {
t.Fatalf("GetOpportunity: %v", err)
}
if strings.TrimSpace(fmt.Sprint(got["title"])) != newTitle {
t.Fatalf("title %q want %q", got["title"], newTitle)
}
}
func TestIntegrationDedupeCompaniesSmoke(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
name := testCRMPrefix + "dedupe-" + suffix
co1, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany: %v", err)
}
co2, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany: %v", err)
}
t.Cleanup(func() {
id1 := strconv.FormatInt(flexInt(co1["id"]), 10)
_, _ = c.DeleteContact(ctx, id1)
})
res, err := DedupeCompanies(ctx, c)
if err != nil {
t.Fatalf("DedupeCompanies: %v", err)
}
if res.Merged < 1 {
t.Fatalf("expected merge, got %+v", res)
}
_ = co2
}
func TestIntegrationFixOpportunityTitleDeal231Pattern(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
stages, err := c.ListDealStages(ctx)
if err != nil || len(stages) == 0 {
t.Fatalf("ListDealStages: %v", err)
}
stageID := int(flexInt(stages[0]["id"]))
opp, err := c.CreateOpportunity(ctx, " @ contoso-test", stageID, "", "EUR", "", 0)
if err != nil {
t.Fatalf("CreateOpportunity: %v", err)
}
id := strconv.FormatInt(flexInt(opp["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
fixed := FixDealTitle(" @ contoso-test")
if _, err := c.UpdateOpportunityTitle(ctx, id, fixed); err != nil {
t.Fatalf("UpdateOpportunityTitle: %v", err)
}
got, err := c.GetOpportunity(ctx, id)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(got["title"]) != "contoso-test" {
t.Fatalf("title %q", got["title"])
}
}
+108
View File
@@ -0,0 +1,108 @@
package onlyoffice
import (
"regexp"
"strings"
)
var multiSpace = regexp.MustCompile(`\s+`)
// sloganSeparators split a company name from a trailing tagline/slogan.
var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"}
// StripSloganSuffix returns the part before an em/en dash tagline, e.g.
// "Affirm — Fraud Engineering" → "Affirm".
func StripSloganSuffix(s string) string {
s = strings.TrimSpace(s)
for _, sep := range sloganSeparators {
if i := strings.Index(s, sep); i > 0 {
return strings.TrimSpace(s[:i])
}
}
return s
}
// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans).
func CompanyGroupingKey(s string) string {
return NormalizeCompanyName(StripSloganSuffix(s))
}
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
func NormalizeCompanyName(s string) string {
return collapseKey(s)
}
// NormalizePersonKey builds a grouping key from first and last name.
func NormalizePersonKey(first, last string) string {
return collapseKey(strings.TrimSpace(first) + " " + strings.TrimSpace(last))
}
// NormalizeOpportunityTitle lowercases and trims a deal title for exact dedupe.
func NormalizeOpportunityTitle(s string) string {
return collapseKey(s)
}
// StripCompanySuffix removes a trailing " @ Company" segment when present.
func StripCompanySuffix(title string) string {
title = strings.TrimSpace(title)
if i := strings.LastIndex(title, " @ "); i >= 0 {
return strings.TrimSpace(title[:i])
}
return title
}
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
// empty-position titles like " @ contoso" to "contoso".
func FixDealTitle(s string) string {
s = strings.TrimSpace(s)
for strings.HasPrefix(s, "@") {
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
}
if s == "" {
return ""
}
if i := strings.Index(s, "@"); i >= 0 {
left := strings.TrimSpace(s[:i])
right := strings.TrimSpace(s[i+1:])
if left == "" && right != "" {
return right
}
if left != "" && right != "" {
return left + " @ " + right
}
}
return s
}
// ContactInfoKey groups contact info rows by type and normalized value.
func ContactInfoKey(infoType, value string) string {
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
}
// MemberDisplayKey normalizes a member displayName for duplicate detection.
func MemberDisplayKey(displayName string) string {
return CompanyGroupingKey(displayName)
}
func collapseKey(s string) string {
s = strings.TrimSpace(s)
s = multiSpace.ReplaceAllString(s, " ")
return strings.ToLower(s)
}
// OpportunityTitlesMatch reports whether two deal titles refer to the same role+company.
func OpportunityTitlesMatch(a, b string) bool {
return DealTitleKey(a, false) == DealTitleKey(b, false)
}
func DealTitleForApplication(position, company string) string {
position = strings.TrimSpace(position)
company = strings.TrimSpace(company)
if company == "" {
return position
}
if position == "" {
return company
}
return position + " @ " + company
}
+104
View File
@@ -0,0 +1,104 @@
package onlyoffice
import "testing"
func TestNormalizeCompanyName(t *testing.T) {
tests := []struct {
in, want string
}{
{" contoso ", "contoso"},
{"Contoso", "contoso"},
{"Acme Corp", "acme corp"},
}
for _, tc := range tests {
if got := NormalizeCompanyName(tc.in); got != tc.want {
t.Errorf("NormalizeCompanyName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestNormalizePersonKey(t *testing.T) {
if got := NormalizePersonKey(" Jane ", " Doe "); got != "jane doe" {
t.Fatalf("got %q", got)
}
}
func TestFixDealTitle(t *testing.T) {
tests := []struct {
in, want string
}{
{" @ contoso", "contoso"},
{"@ contoso", "contoso"},
{"@Acme", "Acme"},
{"Dev@Acme", "Dev @ Acme"},
{"Dev @ Acme", "Dev @ Acme"},
{"Senior Dev @ Acme", "Senior Dev @ Acme"},
{"", ""},
}
for _, tc := range tests {
if got := FixDealTitle(tc.in); got != tc.want {
t.Errorf("FixDealTitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStripSloganSuffix(t *testing.T) {
tests := []struct {
in, want string
}{
{"Affirm — Fraud Engineering", "Affirm"},
{"Affirm - Fraud Engineering", "Affirm"},
{"Affirm – Fraud Engineering", "Affirm"},
{"Affirm", "Affirm"},
{"— leading", "— leading"},
}
for _, tc := range tests {
if got := StripSloganSuffix(tc.in); got != tc.want {
t.Errorf("StripSloganSuffix(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestCompanyGroupingKey(t *testing.T) {
a := CompanyGroupingKey("Affirm")
b := CompanyGroupingKey("Affirm — Fraud Engineering")
if a != b {
t.Fatalf("%q != %q", a, b)
}
}
func TestOpportunityTitlesMatchSlogan(t *testing.T) {
if !OpportunityTitlesMatch("Dev @ Affirm — Fraud Engineering", "Dev @ Affirm") {
t.Fatal("expected match")
}
}
func TestStripCompanySuffix(t *testing.T) {
if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" {
t.Fatalf("got %q", got)
}
if got := StripCompanySuffix("Dev"); got != "Dev" {
t.Fatalf("got %q", got)
}
}
func TestContactInfoKey(t *testing.T) {
if got := ContactInfoKey("Email", " A@B.COM "); got != "email|a@b.com" {
t.Fatalf("got %q", got)
}
}
func TestMemberDisplayKey(t *testing.T) {
if got := MemberDisplayKey(" contoso "); got != "contoso" {
t.Fatalf("got %q", got)
}
}
func TestDealTitleForApplication(t *testing.T) {
if got := DealTitleForApplication("", "contoso"); got != "contoso" {
t.Fatalf("got %q", got)
}
if got := DealTitleForApplication("Dev", "Acme"); got != "Dev @ Acme" {
t.Fatalf("got %q", got)
}
}
-54
View File
@@ -1,54 +0,0 @@
// Package main shows how to drive the internal applications package — the same
// logic that powers `oo-cli applications-sync` — directly from Go code.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// go run ./examples/applications /path/to/cv/applications
//
// Without --apply equivalent, it prints a summary but performs no writes.
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/applications"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: applications <applications-root>")
os.Exit(2)
}
root := os.Args[1]
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
paths, err := applications.Discover(root)
if err != nil {
log.Fatalf("discover: %v", err)
}
fmt.Printf("discovered %d application READMEs under %s\n", len(paths), root)
var apps []applications.Data
for _, p := range paths {
d, err := applications.ParseReadme(p)
if err != nil {
log.Printf("parse %s: %v", p, err)
continue
}
apps = append(apps, d)
}
stats := applications.Sync(context.Background(), client, apps, true /*dryRun*/, true /*verbose*/)
fmt.Printf("\ndry-run stats: %+v\n", stats)
}
+361 -4
View File
@@ -4,14 +4,66 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url" "net/url"
"path"
"strconv"
"strings"
"time"
) )
// FileEntry is a file row from the OnlyOffice Files module or project/task
// file listings (field names follow the Workspace API JSON).
type FileEntry struct {
ID *json.Number `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
FileExst *string `json:"fileExst,omitempty"`
ContentLength *string `json:"contentLength,omitempty"`
FileType *int `json:"fileType,omitempty"`
ViewURL *string `json:"viewUrl,omitempty"`
WebURL *string `json:"webUrl,omitempty"`
FolderID *json.Number `json:"folderId,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
}
// FolderEntry is a folder row from project files listing.
type FolderEntry struct {
ID *json.Number `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
FilesCount *int `json:"filesCount,omitempty"`
FoldersCount *int `json:"foldersCount,omitempty"`
}
// ProjectFilesResponse is the "response" object from GET
// /api/2.0/project/{id}/files — files and folders attached to the project.
type ProjectFilesResponse struct {
Folders []*FolderEntry `json:"folders"`
Files []*FileEntry `json:"files"`
}
// UploadOpportunityFile uploads a single file to a CRM opportunity. // UploadOpportunityFile uploads a single file to a CRM opportunity.
// Returns the decoded "response" object from OnlyOffice. // Returns the decoded "response" object from OnlyOffice.
func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) { func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) {
path := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID)) p := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID))
raw, err := c.uploadMultipart(ctx, path, "file", filePath) raw, err := c.uploadMultipart(ctx, p, "file", filePath)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// GetProjectFiles returns files and folders linked to the project.
func (c *Client) GetProjectFiles(ctx context.Context, projectID string) (*ProjectFilesResponse, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
if projectID == "" {
return nil, fmt.Errorf("project id is required")
}
p := fmt.Sprintf("/api/2.0/project/%s/files.json", url.PathEscape(projectID))
raw, err := c.getJSON(ctx, p)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -19,9 +71,314 @@ func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, fileP
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out map[string]any if len(resp) == 0 || string(resp) == "null" {
return &ProjectFilesResponse{}, nil
}
var out ProjectFilesResponse
if err := json.Unmarshal(resp, &out); err != nil { if err := json.Unmarshal(resp, &out); err != nil {
return nil, err return nil, err
} }
return out, nil return &out, nil
}
// GetTaskFiles returns files attached to a project task.
func (c *Client) GetTaskFiles(ctx context.Context, taskID string) ([]*FileEntry, error) {
if taskID == "" {
return nil, fmt.Errorf("task id is required")
}
p := fmt.Sprintf("/api/2.0/project/task/%s/files.json", url.PathEscape(taskID))
raw, err := c.getJSON(ctx, p)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if len(resp) == 0 || string(resp) == "null" {
return nil, nil
}
var list []*FileEntry
if err := json.Unmarshal(resp, &list); err != nil {
return nil, err
}
return list, nil
}
// UploadTaskFile uploads a file into the task's project Documents folder, then
// attaches the new file id to the task. OnlyOffice POST .../task/{id}/files
// expects existing file IDs, not a multipart body.
func (c *Client) UploadTaskFile(ctx context.Context, taskID, localPath string) (*FileEntry, error) {
if taskID == "" {
return nil, fmt.Errorf("task id is required")
}
task, err := c.GetTaskByID(ctx, taskID)
if err != nil {
return nil, err
}
pid := projectIDFromTaskMap(task)
if pid == "" {
return nil, fmt.Errorf("task %s: cannot resolve project id for upload", taskID)
}
entry, err := c.UploadProjectFile(ctx, pid, localPath)
if err != nil {
return nil, err
}
nid := int(FileEntryNumericID(entry))
if nid == 0 {
return nil, fmt.Errorf("upload returned no file id")
}
if err := c.AttachFilesToTask(ctx, taskID, nid); err != nil {
return nil, err
}
return entry, nil
}
// AttachFilesToTask links existing Documents-module files to a task.
func (c *Client) AttachFilesToTask(ctx context.Context, taskID string, fileIDs ...int) error {
if taskID == "" || len(fileIDs) == 0 {
return fmt.Errorf("task id and at least one file id are required")
}
v := url.Values{}
for _, id := range fileIDs {
v.Add("files", strconv.Itoa(id))
}
p := fmt.Sprintf("/api/2.0/project/task/%s/files.json", url.PathEscape(taskID))
if _, err := c.postForm(ctx, p, v); err != nil {
p2 := fmt.Sprintf("/api/2.0/project/task/%s/files", url.PathEscape(taskID))
if _, err2 := c.postForm(ctx, p2, v); err2 != nil {
return fmt.Errorf("attach files to task: %w (retry: %v)", err, err2)
}
}
return nil
}
func projectIDFromTaskMap(m map[string]any) string {
if m == nil {
return ""
}
if po, ok := m["projectOwner"].(map[string]any); ok {
if id, ok := po["id"]; ok {
switch x := id.(type) {
case float64:
return strconv.FormatInt(int64(x), 10)
case int:
return strconv.Itoa(x)
case string:
return x
}
}
}
return ""
}
// DetachTaskFile removes a file attachment from the task (file remains in Documents).
func (c *Client) DetachTaskFile(ctx context.Context, taskID, fileID string) error {
if taskID == "" || fileID == "" {
return fmt.Errorf("task id and file id are required")
}
q := url.Values{}
q.Set("fileid", fileID)
p := fmt.Sprintf("/api/2.0/project/task/%s/files.json?%s", url.PathEscape(taskID), q.Encode())
if _, err := c.deleteReq(ctx, p); err != nil {
p2 := fmt.Sprintf("/api/2.0/project/task/%s/files?%s", url.PathEscape(taskID), q.Encode())
if _, err2 := c.deleteReq(ctx, p2); err2 != nil {
return fmt.Errorf("detach task file: %w (retry: %v)", err, err2)
}
}
return nil
}
// projectFolderID resolves the Documents folder id for project file uploads.
func (c *Client) projectFolderID(ctx context.Context, projectID string) (string, error) {
m, err := c.GetProjectByID(ctx, projectID)
if err != nil {
return "", err
}
if v, ok := m["projectFolder"]; ok && v != nil {
switch x := v.(type) {
case float64:
return strconv.FormatInt(int64(x), 10), nil
case json.Number:
return x.String(), nil
case string:
if x != "" {
return x, nil
}
}
}
// Fallback: first folder from project files listing.
pf, err := c.GetProjectFiles(ctx, projectID)
if err != nil {
return "", err
}
if len(pf.Folders) > 0 && pf.Folders[0].ID != nil {
return pf.Folders[0].ID.String(), nil
}
return "", fmt.Errorf("project %s has no projectFolder and no folders in files listing", projectID)
}
// UploadProjectFile uploads a file into the project's Documents folder.
func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath string) (*FileEntry, error) {
folderID, err := c.projectFolderID(ctx, projectID)
if err != nil {
return nil, err
}
// Workspace DocumentsApi.UploadFile: POST .../{folderId}/upload (multipart or raw stream).
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)
}
// GetFile returns file metadata including viewUrl for download.
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
if fileID == "" {
return nil, fmt.Errorf("file id is required")
}
p := fmt.Sprintf("/api/2.0/files/file/%s.json", url.PathEscape(fileID))
raw, err := c.getJSON(ctx, p)
if err != nil {
return nil, err
}
return decodeResponseFileEntry(raw)
}
// RenameFile sets a new title (including extension) for the file.
func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) {
if fileID == "" || newTitle == "" {
return nil, fmt.Errorf("file id and new title are required")
}
p := fmt.Sprintf("/api/2.0/files/file/%s.json", url.PathEscape(fileID))
raw, err := c.putJSON(ctx, p, map[string]string{"title": newTitle})
if err != nil {
return nil, err
}
return decodeResponseFileEntry(raw)
}
type deleteFilesBody struct {
FileIDs []int `json:"fileIds"`
FolderIDs []int `json:"folderIds"`
}
// DeleteFiles permanently deletes files by numeric id (Documents module).
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)
}
return err
}
// DownloadFile streams file bytes from the file's viewUrl using the same auth
// as API calls. Writes into dst.
func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) {
f, err := c.GetFile(ctx, fileID)
if err != nil {
return 0, err
}
if f.ViewURL == nil || *f.ViewURL == "" {
return 0, fmt.Errorf("file %s has no viewUrl", fileID)
}
downloadURL := c.resolveAPIURL(*f.ViewURL)
auth, err := c.authHeader()
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, 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 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return 0, fmt.Errorf("GET viewUrl: %d %s", resp.StatusCode, truncate(string(b), 400))
}
n, err := io.Copy(dst, resp.Body)
return n, err
}
func (c *Client) resolveAPIURL(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ref
}
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
return ref
}
base := c.baseURL()
if strings.HasPrefix(ref, "/") {
u, err := url.Parse(base)
if err != nil {
return base + ref
}
u.Path = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/") + ref
}
return base + "/" + strings.TrimPrefix(ref, "/")
}
func decodeResponseFileEntry(raw json.RawMessage) (*FileEntry, error) {
var env struct {
Response *FileEntry `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
if env.Response == nil {
return nil, fmt.Errorf("empty file response")
}
return env.Response, nil
}
// FileEntryNumericID returns the file id as int64, or 0 if missing/invalid.
func FileEntryNumericID(f *FileEntry) int64 {
if f == nil || f.ID == nil {
return 0
}
n, err := f.ID.Int64()
if err != nil {
return 0
}
return n
}
// FileEntryTitle returns the title or empty string.
func FileEntryTitle(f *FileEntry) string {
if f == nil || f.Title == nil {
return ""
}
return *f.Title
}
// SafeLocalFileName sanitizes a server title for use as a local filename.
func SafeLocalFileName(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "download"
}
base := path.Base(title)
base = strings.ReplaceAll(base, "\x00", "")
if base == "." || base == "/" {
return "download"
}
return base
} }
+175
View File
@@ -0,0 +1,175 @@
//go:build integration
package onlyoffice
import (
"bytes"
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
// TestIntegrationProjectFilesFlow creates a throwaway project, uploads a file
// into its Documents folder, lists, downloads, renames, deletes, then removes
// the project. Destructive — only run against instances you own.
func TestIntegrationProjectFilesFlow(t *testing.T) {
c := liveClient(t)
t.Cleanup(func() { cleanupTestProjects(t, c) })
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + "files-" + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "go-onlyoffice project files integration",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
}
pid := strconv.Itoa(*project.ID)
ctx := context.Background()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "hello.txt")
content := []byte("integration project file " + suffix + "\n")
if err := os.WriteFile(localPath, content, 0o600); err != nil {
t.Fatal(err)
}
entry, err := c.UploadProjectFile(ctx, pid, localPath)
if err != nil {
t.Fatalf("UploadProjectFile: %v", err)
}
if entry == nil || entry.ID == nil {
t.Fatalf("upload returned no file: %+v", entry)
}
fileID := entry.ID.String()
pf, err := c.GetProjectFiles(ctx, pid)
if err != nil {
t.Fatalf("GetProjectFiles: %v", err)
}
found := false
for _, f := range pf.Files {
if f != nil && f.ID != nil && f.ID.String() == fileID {
found = true
break
}
}
if !found {
t.Logf("uploaded file id=%s not in project files list (may still be ok); folders=%d files=%d",
fileID, len(pf.Folders), len(pf.Files))
}
var buf bytes.Buffer
n, err := c.DownloadFile(ctx, fileID, &buf)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if n != int64(len(content)) || !bytes.Equal(buf.Bytes(), content) {
t.Fatalf("download mismatch: got %d bytes %q want %d bytes", n, buf.String(), len(content))
}
newTitle := "renamed-" + suffix + ".txt"
renamed, err := c.RenameFile(ctx, fileID, newTitle)
if err != nil {
t.Fatalf("RenameFile: %v", err)
}
if renamed == nil || renamed.Title == nil || *renamed.Title != newTitle {
t.Fatalf("rename result: %+v", renamed)
}
nid := int(FileEntryNumericID(entry))
if nid == 0 {
t.Fatal("file id 0 for delete")
}
if err := c.DeleteFiles(ctx, []int{nid}); err != nil {
t.Fatalf("DeleteFiles: %v", err)
}
}
func TestIntegrationTaskFilesFlow(t *testing.T) {
c := liveClient(t)
t.Cleanup(func() { cleanupTestProjects(t, c) })
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + "taskfiles-" + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "go-onlyoffice task files integration",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
}
start := Time(time.Now().AddDate(0, 0, -1))
deadline := Time(time.Now().AddDate(0, 0, 1))
task, err := c.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID,
Title: "task for file attach " + suffix,
Description: "integration",
StartDate: start,
Deadline: deadline,
Priority: int(TaskPriorityNormal),
})
if err != nil {
t.Fatalf("CreateProjectTask: %v", err)
}
if task.ID == nil {
t.Fatal("task without id")
}
tid := strconv.Itoa(*task.ID)
ctx := context.Background()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "attach.txt")
content := []byte("task attachment " + suffix + "\n")
if err := os.WriteFile(localPath, content, 0o600); err != nil {
t.Fatal(err)
}
up, err := c.UploadTaskFile(ctx, tid, localPath)
if err != nil {
t.Fatalf("UploadTaskFile: %v", err)
}
if up == nil || up.ID == nil {
t.Fatalf("upload: %+v", up)
}
fileID := up.ID.String()
list, err := c.GetTaskFiles(ctx, tid)
if err != nil {
t.Fatalf("GetTaskFiles: %v", err)
}
found := false
for _, f := range list {
if f != nil && f.ID != nil && f.ID.String() == fileID {
found = true
break
}
}
if !found {
t.Fatalf("uploaded file not in GetTaskFiles: %#v", list)
}
if err := c.DetachTaskFile(ctx, tid, fileID); err != nil {
t.Fatalf("DetachTaskFile: %v", err)
}
list2, err := c.GetTaskFiles(ctx, tid)
if err != nil {
t.Fatalf("GetTaskFiles after detach: %v", err)
}
for _, f := range list2 {
if f != nil && f.ID != nil && f.ID.String() == fileID {
t.Fatalf("file still attached after detach: %v", fileID)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package onlyoffice
import (
"encoding/json"
"os"
"testing"
)
func TestDecodeProjectFilesEnvelope(t *testing.T) {
raw, err := os.ReadFile("testdata/project_files_response.json")
if err != nil {
t.Fatal(err)
}
resp, err := responseField(json.RawMessage(raw), "response")
if err != nil {
t.Fatal(err)
}
var pf ProjectFilesResponse
if err := json.Unmarshal(resp, &pf); err != nil {
t.Fatal(err)
}
if len(pf.Folders) != 1 || pf.Folders[0].Title == nil || *pf.Folders[0].Title != "Subfolder" {
t.Fatalf("folders: %+v", pf.Folders)
}
if len(pf.Files) != 1 || pf.Files[0].Title == nil || *pf.Files[0].Title != "readme.txt" {
t.Fatalf("files: %+v", pf.Files)
}
if pf.Files[0].ID == nil || pf.Files[0].ID.String() != "100" {
t.Fatalf("file id: %v", pf.Files[0].ID)
}
}
func TestDecodeTaskFilesEnvelope(t *testing.T) {
raw, err := os.ReadFile("testdata/task_files_response.json")
if err != nil {
t.Fatal(err)
}
resp, err := responseField(json.RawMessage(raw), "response")
if err != nil {
t.Fatal(err)
}
var list []*FileEntry
if err := json.Unmarshal(resp, &list); err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Title == nil || *list[0].Title != "attach.pdf" {
t.Fatalf("list: %+v", list)
}
}
func TestProjectIDFromTaskMap(t *testing.T) {
m := map[string]any{
"projectOwner": map[string]any{"id": float64(33)},
}
if got := projectIDFromTaskMap(m); got != "33" {
t.Fatalf("got %q", got)
}
if projectIDFromTaskMap(nil) != "" {
t.Fatal("expected empty")
}
}
func TestSafeLocalFileName(t *testing.T) {
if got := SafeLocalFileName(" foo/bar.txt "); got != "bar.txt" {
t.Fatalf("got %q", got)
}
if got := SafeLocalFileName(""); got != "download" {
t.Fatalf("got %q", got)
}
}
+32 -1
View File
@@ -1,14 +1,45 @@
module github.com/eslider/go-onlyoffice module github.com/eslider/go-onlyoffice
go 1.22.2 go 1.25.0
require ( require (
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v0.12.1
github.com/eslider/go-xls/v2 v2.1.0
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
) )
require ( require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // 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/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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-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/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // 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
) )
+83
View File
@@ -1,16 +1,99 @@
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
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/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=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4 h1:6KzMkQeAF56rggw2NZu1L+TH7j9+DM1/2Kmh7KUxg1I=
github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
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= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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-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=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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= 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/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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+111 -102
View File
@@ -1,8 +1,10 @@
package onlyoffice package onlyoffice
// Lightweight JSON/form/multipart helpers complementing the typed Query() // Transport-layer helpers used by the untyped domain methods (CRM, calendar,
// abstraction in onlyoffice.go. These are used by the Calendar, CRM, and // tasks, files). Authentication lives in auth.go; the typed Request/Query
// subtask helpers that talk to non-JSON endpoints (form-encoded or multipart). // abstraction lives in request.go. These helpers deliberately share the
// `*Client` state with the typed path so token refresh, base URL, and
// self-id caching are handled once.
import ( import (
"bytes" "bytes"
@@ -16,92 +18,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// ensureToken refreshes the authentication token when missing or expired.
// Mirrors the logic inline in Query() but is safe to call from helpers that
// bypass the typed Request abstraction.
func (c *Client) ensureToken() error {
if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) {
return nil
}
tok, err := c.Auth(c.credentials)
if err != nil {
return err
}
c.token = tok
return nil
}
// authHeader returns the value for the Authorization header, ensuring a token.
func (c *Client) authHeader() (string, error) {
if err := c.ensureToken(); err != nil {
return "", err
}
return c.token.Value, nil
}
// Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token is
// fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs a
// POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron, watchers)
// because it guarantees that a stalled auth call will not block the caller
// past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// baseURL returns the configured base URL without trailing slash. // baseURL returns the configured base URL without trailing slash.
func (c *Client) baseURL() string { func (c *Client) baseURL() string {
return strings.TrimRight(c.credentials.Url, "/") return strings.TrimRight(c.credentials.Url, "/")
@@ -151,6 +69,66 @@ func (c *Client) ResponseArray(ctx context.Context, path string) ([]map[string]a
return list, nil return list, nil
} }
// ResponseObject executes a GET and decodes the "response" field into a map.
// Returns (nil, nil) when the field is JSON null or absent. Companion to
// ResponseArray — factored out to eliminate the ~15 identical decode blocks
// in crm.go / tasks.go / calendar.go.
func (c *Client) ResponseObject(ctx context.Context, path string) (map[string]any, error) {
raw, err := c.getJSON(ctx, path)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// postFormObject issues an authenticated POST (form-encoded) and decodes the
// "response" field into a map.
func (c *Client) postFormObject(ctx context.Context, path string, fields url.Values) (map[string]any, error) {
raw, err := c.postForm(ctx, path, fields)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// putFormObject issues an authenticated PUT (form-encoded) and decodes the
// "response" field into a map.
func (c *Client) putFormObject(ctx context.Context, path string, fields url.Values) (map[string]any, error) {
raw, err := c.putForm(ctx, path, fields)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// deleteObject issues an authenticated DELETE and decodes the "response"
// field into a map.
func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, path)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// 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.
func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if len(resp) == 0 || string(resp) == "null" {
return nil, nil
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// getJSON issues an authenticated GET and returns the raw response body. // getJSON issues an authenticated GET and returns the raw response body.
func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) { func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) {
auth, err := c.authHeader() auth, err := c.authHeader()
@@ -242,25 +220,56 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e
return raw, nil return raw, nil
} }
// SelfUserID returns the ID of the authenticated user (people/@self), cached. // putJSONObject issues an authenticated PUT with JSON body and decodes response.
func (c *Client) SelfUserID(ctx context.Context) (string, error) { func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
if c.selfID != "" { raw, err := c.putJSON(ctx, path, body)
return c.selfID, nil
}
raw, err := c.getJSON(ctx, "/api/2.0/people/@self.json")
if err != nil { if err != nil {
return "", err return nil, err
} }
var env struct { return unmarshalResponseObject(raw)
Response struct {
ID string `json:"id"`
} `json:"response"`
} }
if err := json.Unmarshal(raw, &env); err != nil {
return "", err // 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()
if err != nil {
return nil, err
} }
c.selfID = env.Response.ID var rdr io.Reader
return c.selfID, nil 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.MethodPut, 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("PUT JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
} }
// uploadMultipart posts a single file to path under the given form field name. // uploadMultipart posts a single file to path under the given form field name.
-430
View File
@@ -1,430 +0,0 @@
// Package applications syncs job application README trees into OnlyOffice CRM
// (ported from cv/bin/office/sync-applications.py).
package applications
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
)
var docExt = map[string]bool{".pdf": true, ".docx": true, ".xlsx": true, ".doc": true, ".xls": true}
type RecruiterInfo struct {
First, Last, JobTitle, Company, Email, Phone, LinkedIn string
}
type Data struct {
Path string
Folder string
Position string
Company string
Location string
Salary string
SalaryValue float64
Contract string
Source string
Link string
Recruiter RecruiterInfo
Summary string
Documents []string
}
func Discover(base string) ([]string, error) {
var out []string
err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "pdfs" {
return fs.SkipDir
}
return nil
}
if filepath.Base(path) != "README.md" {
return nil
}
dir := filepath.Dir(path)
if filepath.Clean(dir) == filepath.Clean(base) {
return nil
}
out = append(out, path)
return nil
})
return out, err
}
func ParseReadme(path string) (Data, error) {
b, err := os.ReadFile(path)
if err != nil {
return Data{}, err
}
text := string(b)
app := Data{Path: path}
app.Folder = relFolder(path)
app.Position = extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Company = extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`)
if app.Company != "" {
app.Company = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(app.Company, "$1")
app.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(app.Company), "")
}
app.Location = extract(text, `(?i)\*\*Location\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Salary = extract(text, `(?i)\*\*(?:Salary|Gehalt|Rate)\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.SalaryValue = parseSalary(app.Salary)
app.Contract = extract(text, `(?i)\*\*Contract\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Link = extract(text, `(?i)\*\*Link\*\*:\s*(.+?)(?:\s{2,}|\n)`)
if m := regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`).FindStringSubmatch(app.Link); len(m) > 2 {
app.Link = m[2]
}
app.Source = extract(text, `(?i)\*\*Source\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Recruiter = parseRecruiter(text)
if app.Source == "" {
app.Source = filepath.Base(filepath.Dir(filepath.Dir(path)))
}
if app.Company == "" || strings.EqualFold(app.Company, "undisclosed") || app.Company == "?" || strings.EqualFold(app.Company, "tbd") {
parts := strings.Split(filepath.Base(filepath.Dir(path)), "-")
if len(parts) >= 2 {
app.Company = titleWord(parts[0])
}
}
app.Summary = buildSummary(app, text)
app.Documents = discoverDocs(filepath.Dir(path))
return app, nil
}
func relFolder(readmePath string) string {
// Python: path.parent.relative_to(path.parent.parent.parent.parent) — approximate: last 3 parts
p := filepath.Clean(readmePath)
parts := strings.Split(p, string(filepath.Separator))
if len(parts) >= 3 {
return filepath.Join(parts[len(parts)-3:]...)
}
return filepath.Dir(readmePath)
}
func extract(text, pat string) string {
re := regexp.MustCompile(pat)
m := re.FindStringSubmatch(text)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func parseSalary(raw string) float64 {
if raw == "" {
return 0
}
cleaned := strings.ReplaceAll(strings.ReplaceAll(raw, ",", ""), ".", "")
if m := regexp.MustCompile(`(\d{4,6})`).FindAllStringSubmatch(cleaned, -1); len(m) > 0 {
var maxv int
for _, x := range m {
n, _ := strconv.Atoi(x[1])
if n > maxv {
maxv = n
}
}
return float64(maxv)
}
if m := regexp.MustCompile(`(?i)(\d{2,4})\s*/\s*day`).FindStringSubmatch(raw); len(m) > 1 {
n, _ := strconv.ParseFloat(m[1], 64)
return n * 220
}
return 0
}
func parseRecruiter(text string) RecruiterInfo {
var r RecruiterInfo
raw := extract(text, `(?i)\*\*(?:Recruiter|Consultant)\*\*:\s*(.+?)(?:\s{2,}|\n)`)
if raw == "" {
return r
}
if m := regexp.MustCompile(`<([^>]+@[^>]+)>`).FindStringSubmatch(raw); len(m) > 1 {
r.Email = strings.TrimSpace(m[1])
if i := strings.Index(raw, "<"); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
raw = strings.TrimRight(raw, " —–-")
}
if m := regexp.MustCompile(`(?i)\s+@\s+(.+)$`).FindStringSubmatch(raw); len(m) > 1 {
r.Company = strings.TrimSpace(m[1])
if i := strings.Index(raw, m[0]); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
}
if m := regexp.MustCompile(`,?\s*\[([^\]]+)\]\([^)]+\)`).FindStringSubmatch(raw); len(m) > 1 {
r.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(m[1]), "")
if i := strings.Index(raw, m[0]); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
}
parts := strings.SplitN(raw, ",", 2)
namePart := strings.TrimSpace(parts[0])
if len(parts) > 1 {
r.JobTitle = strings.TrimSpace(parts[1])
}
toks := strings.Fields(namePart)
if len(toks) >= 2 {
r.First = toks[0]
r.Last = strings.Join(toks[1:], " ")
} else if len(toks) == 1 {
r.First = toks[0]
}
if m := regexp.MustCompile(`(?i)\*\*?(?:Telefon|Phone)\*\*?:\s*([\d+\-\s]+)`).FindStringSubmatch(text); len(m) > 1 {
r.Phone = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?i)\*\*LinkedIn\*\*:\s*(https://[^\s]+)`).FindStringSubmatch(text); len(m) > 1 {
r.LinkedIn = strings.TrimSpace(m[1])
}
return r
}
func buildSummary(app Data, text string) string {
var lines []string
lines = append(lines, "Position: "+app.Position)
lines = append(lines, "Company: "+app.Company)
if app.Location != "" {
lines = append(lines, "Location: "+app.Location)
}
if app.Salary != "" {
lines = append(lines, "Salary: "+app.Salary)
}
if app.Contract != "" {
lines = append(lines, "Contract: "+app.Contract)
}
if app.Source != "" {
lines = append(lines, "Source: "+app.Source)
}
if app.Link != "" {
lines = append(lines, "Link: "+app.Link)
}
lines = append(lines, "Folder: "+app.Folder)
if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 {
lines = append(lines, "", "Status:", strings.TrimSpace(m[1]))
}
// RE2 does not support lookahead; consume the trailing delimiter (\n## or EOS)
// with a non-capturing alternation — the captured group 1 stops right before it.
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?:\n## |$)`).FindStringSubmatch(text); len(m) > 1 {
ft := strings.TrimSpace(m[1])
if len(ft) > 500 {
ft = ft[:500] + "..."
}
lines = append(lines, "", "Fit Assessment:", ft)
}
return strings.Join(lines, "\n")
}
func discoverDocs(dir string) []string {
var docs []string
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if docExt[ext] {
docs = append(docs, filepath.Join(dir, e.Name()))
}
}
return docs
}
type Stats struct {
Companies, Persons, Deals, Tasks, Docs, Notes int
}
// Sync mirrors Python sync_to_crm.
func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, verbose bool) Stats {
var st Stats
deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
const stageInitial = 1
for _, app := range apps {
dealTitle := app.Position
if app.Company != "" {
dealTitle = app.Position + " @ " + app.Company
}
fmt.Println(strings.Repeat("─", 60))
fmt.Println(" ", app.Folder)
fmt.Println(" ", dealTitle)
if verbose {
// minimal verbose
if app.Salary != "" {
fmt.Printf(" Salary: %s (parsed: %.0f)\n", app.Salary, app.SalaryValue)
}
}
action := "would create"
if !dryRun {
action = "creating"
}
companyID := 0
if app.Company != "" {
if !dryRun {
if ex, _ := client.FindCompany(ctx, app.Company); ex != nil {
companyID = int(flexID(ex["id"]))
fmt.Printf(" [company] found: [%d] %s\n", companyID, app.Company)
} else {
co, err := client.CreateCompany(ctx, app.Company)
if err != nil {
fmt.Printf(" [company] ERR: %v\n", err)
} else {
companyID = int(flexID(co["id"]))
fmt.Printf(" [company] created: [%d] %s\n", companyID, app.Company)
if app.Link != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(companyID), "Website", app.Link, "Work", false)
}
}
}
} else {
fmt.Printf(" [company] %s: %s\n", action, app.Company)
}
st.Companies++
}
personID := 0
r := app.Recruiter
if r.First != "" && r.Last != "" {
if !dryRun {
if ex, _ := client.FindPerson(ctx, r.First, r.Last); ex != nil {
personID = int(flexID(ex["id"]))
fmt.Printf(" [person] found: [%d] %s %s\n", personID, r.First, r.Last)
} else {
p, err := client.CreatePerson(ctx, r.First, r.Last, companyID, r.JobTitle, "")
if err != nil {
fmt.Printf(" [person] ERR: %v\n", err)
} else {
personID = int(flexID(p["id"]))
fmt.Printf(" [person] created: [%d] %s %s\n", personID, r.First, r.Last)
if r.Email != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Email", r.Email, "Work", true)
}
if r.Phone != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Phone", r.Phone, "Work", false)
}
if r.LinkedIn != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "LinkedIn", r.LinkedIn, "Work", false)
}
}
}
} else {
fmt.Printf(" [person] %s: %s %s\n", action, r.First, r.Last)
}
st.Persons++
}
var dealID int
if dryRun {
fmt.Printf(" [deal] %s: %s\n", action, dealTitle)
} else {
if ex := findExistingOpp(ctx, client, dealTitle); ex != nil {
dealID = int(flexID(ex["id"]))
fmt.Printf(" [deal] found: [%d] %s\n", dealID, dealTitle)
} else {
d, err := client.CreateOpportunity(ctx, dealTitle, stageInitial, "", "EUR", "", app.SalaryValue)
if err != nil {
fmt.Printf(" [deal] ERR: %v\n", err)
} else {
dealID = int(flexID(d["id"]))
fmt.Printf(" [deal] created: [%d] %s\n", dealID, dealTitle)
}
}
}
st.Deals++
if !dryRun && dealID != 0 {
if companyID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID))
}
if personID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID))
}
}
if app.Summary != "" {
if dryRun {
fmt.Printf(" [note] %s: application summary\n", action)
} else if dealID != 0 {
if _, err := client.AddHistoryNote(ctx, "opportunity", dealID, app.Summary, 0); err == nil {
fmt.Println(" [note] added application summary")
st.Notes++
}
}
}
titles := []string{"Write application", "Apply application", "Send CV"}
if dryRun {
for _, t := range titles {
fmt.Printf(" [task] %s: %s\n", action, t)
}
} else if dealID != 0 {
for _, t := range titles {
if _, err := client.CreateCRMTask(ctx, t, deadline, 2, 0, "opportunity", dealID, ""); err == nil {
fmt.Printf(" [task] created: %s\n", t)
st.Tasks++
}
}
}
for _, doc := range app.Documents {
if dryRun {
fmt.Printf(" [doc] %s: %s\n", action, filepath.Base(doc))
} else if dealID != 0 {
if _, err := client.UploadOpportunityFile(ctx, strconv.Itoa(dealID), doc); err == nil {
fmt.Printf(" [doc] uploaded: %s\n", filepath.Base(doc))
st.Docs++
}
}
}
}
mode := "DRY RUN"
if !dryRun {
mode = "APPLIED"
}
fmt.Println(strings.Repeat("═", 60))
fmt.Printf(" %s: deals=%d companies=%d persons=%d notes=%d tasks=%d docs=%d\n",
mode, st.Deals, st.Companies, st.Persons, st.Notes, st.Tasks, st.Docs)
return st
}
func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} {
deals, total, _ := c.ListOpportunities(ctx, 100, 0)
for _, d := range deals {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
return d
}
}
if total > 100 {
deals2, _, _ := c.ListOpportunities(ctx, 100, 100)
for _, d := range deals2 {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
return d
}
}
}
return nil
}
func titleWord(s string) string {
if s == "" {
return ""
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
func flexID(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
-53
View File
@@ -1,53 +0,0 @@
package applications
import "testing"
func TestExtractField(t *testing.T) {
text := "**Position**: Senior Dev\n**Company**: Acme\n"
if g := extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Senior Dev" {
t.Fatalf("position: %q", g)
}
if g := extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Acme" {
t.Fatalf("company: %q", g)
}
}
func TestParseSalary(t *testing.T) {
if v := parseSalary("€50,000"); v < 40000 {
t.Fatalf("annual: %v", v)
}
if v := parseSalary("400 / day"); v != 400*220 {
t.Fatalf("daily: %v", v)
}
}
// TestBuildSummaryFitAssessment covers the RE2-safe replacement of a previously
// lookahead-based regex that would panic at MustCompile time on Go regexp.
func TestBuildSummaryFitAssessment(t *testing.T) {
app := Data{Position: "SRE", Company: "Acme", Folder: "/tmp"}
withNextSection := "## Fit Assessment\nGreat fit overall.\nStrong background.\n## Next section\nUnrelated."
summary := buildSummary(app, withNextSection)
if !contains(summary, "Fit Assessment:") {
t.Fatalf("missing header in summary:\n%s", summary)
}
if !contains(summary, "Great fit overall.") {
t.Fatalf("missing body in summary:\n%s", summary)
}
if contains(summary, "Unrelated.") {
t.Fatalf("bled into next section:\n%s", summary)
}
eof := "## Fit Assessment\nOnly content at EOF."
summary = buildSummary(app, eof)
if !contains(summary, "Only content at EOF.") {
t.Fatalf("EOF case missing body:\n%s", summary)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
-517
View File
@@ -1,517 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/applications"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "oo-cli",
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
}
// Execute runs the root command; the binary in cmd/oo-cli is a thin wrapper.
func Execute() error {
return rootCmd.Execute()
}
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO() (*onlyoffice.Client, error) {
_ = godotenv.Load()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.Authenticate(); err != nil {
return nil, err
}
return c, nil
}
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func init() {
rootCmd.AddCommand(cmdCalList())
rootCmd.AddCommand(cmdCalEvents())
rootCmd.AddCommand(cmdCalAdd())
rootCmd.AddCommand(cmdCalDel())
rootCmd.AddCommand(cmdTaskList())
rootCmd.AddCommand(cmdTaskAdd())
rootCmd.AddCommand(cmdSubtaskAdd())
rootCmd.AddCommand(cmdTaskUpdate())
rootCmd.AddCommand(cmdCRMContacts())
rootCmd.AddCommand(cmdCRMAddContact())
rootCmd.AddCommand(cmdCRMDeals())
rootCmd.AddCommand(cmdCRMAddDeal())
rootCmd.AddCommand(cmdCRMCases())
rootCmd.AddCommand(cmdAppsSync())
}
func cmdCalList() *cobra.Command {
return &cobra.Command{
Use: "cal-list",
Short: "List calendars (default date span)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
out, err := c.ListCalendars(cmd.Context(), "", "")
if err != nil {
return err
}
printJSON(out)
return nil
},
}
}
func cmdCalEvents() *cobra.Command {
var start, end string
cmd := &cobra.Command{
Use: "cal-events",
Short: "List calendar data for period (default: next 7 days)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if start == "" || end == "" {
// Match list-events.py default window
start = time.Now().Format("2006-01-02")
end = time.Now().AddDate(0, 0, 7).Format("2006-01-02")
}
out, err := c.ListEvents(cmd.Context(), start, end)
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD")
cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD")
return cmd
}
func cmdCalAdd() *cobra.Command {
var cal, desc string
var allDay bool
cmd := &cobra.Command{
Use: "cal-add TITLE START END",
Short: "Add calendar event",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay)
if err != nil {
return err
}
printJSON(ev)
return nil
},
}
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env)")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().BoolVar(&allDay, "all-day", false, "")
return cmd
}
func cmdCalDel() *cobra.Command {
return &cobra.Command{
Use: "cal-delete EVENT_ID [EVENT_ID...]",
Short: "Delete calendar events",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteEvent(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
}
return nil
},
}
}
func cmdTaskList() *cobra.Command {
var project, status string
var all, verbose bool
cmd := &cobra.Command{
Use: "task-list",
Short: "List project tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
var tasks []map[string]interface{}
if all {
tasks, err = c.ListAllTasks(cmd.Context(), status)
} else {
tasks, err = c.ListTasks(cmd.Context(), project, status)
}
if err != nil {
return err
}
for _, t := range tasks {
fmt.Printf("[%v] %v status=%v\n", t["id"], t["title"], t["status"])
if verbose {
d, _ := c.GetTaskByID(cmd.Context(), fmt.Sprint(t["id"]))
subs, _ := d["subtasks"].([]interface{})
for _, s := range subs {
sm, _ := s.(map[string]interface{})
fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"])
}
}
}
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id")
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
return cmd
}
func cmdTaskAdd() *cobra.Command {
var project, desc, deadline string
var prio string
cmd := &cobra.Command{
Use: "task-add TITLE",
Short: "Add project task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
p := 0
if prio == "high" {
p = 1
}
if prio == "low" {
p = -1
}
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().StringVar(&deadline, "deadline", "", "")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd
}
func cmdSubtaskAdd() *cobra.Command {
return &cobra.Command{
Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add subtask(s)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
parent := args[0]
for _, title := range args[1:] {
out, err := c.AddSubtask(cmd.Context(), parent, title)
if err != nil {
return err
}
printJSON(out)
}
return nil
},
}
}
func cmdTaskUpdate() *cobra.Command {
var del bool
cmd := &cobra.Command{
Use: "task-update TASK_ID open|closed",
Short: "Update task status or delete",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
id := args[0]
if del {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
return nil
}
if len(args) < 2 {
return fmt.Errorf("need status or --delete")
}
out, err := c.UpdateTaskStatus(cmd.Context(), id, args[1])
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().BoolVar(&del, "delete", false, "delete task")
return cmd
}
func cmdCRMContacts() *cobra.Command {
var companies, persons bool
var search string
cmd := &cobra.Command{
Use: "crm-contacts",
Short: "List CRM contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, _, err := c.ListContacts(cmd.Context(), 50, 0, search)
if err != nil {
return err
}
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if companies && !isCo {
continue
}
if persons && isCo {
continue
}
printJSON(row)
}
return nil
},
}
cmd.Flags().BoolVar(&companies, "companies", false, "")
cmd.Flags().BoolVar(&persons, "persons", false, "")
cmd.Flags().StringVar(&search, "search", "", "")
return cmd
}
func cmdCRMAddContact() *cobra.Command {
var company, email, website, linkedin string
var personFirst, personLast string
var companyID int
cmd := &cobra.Command{
Use: "crm-add-contact",
Short: "Add company or person",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if company != "" {
out, err := c.CreateCompany(cmd.Context(), company)
if err != nil {
return err
}
cid := int(flexIDMap(out["id"]))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Website", website, "Work", false)
}
printJSON(out)
return nil
}
if personFirst != "" && personLast != "" {
out, err := c.CreatePerson(cmd.Context(), personFirst, personLast, companyID, "", "")
if err != nil {
return err
}
pid := fmt.Sprint(int(flexIDMap(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
printJSON(out)
return nil
}
return fmt.Errorf("set --company or --person-first and --person-last")
},
}
cmd.Flags().StringVar(&company, "company", "", "")
cmd.Flags().StringVar(&personFirst, "person-first", "", "")
cmd.Flags().StringVar(&personLast, "person-last", "", "")
cmd.Flags().IntVar(&companyID, "company-id", 0, "")
cmd.Flags().StringVar(&email, "email", "", "")
cmd.Flags().StringVar(&website, "website", "", "")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "")
return cmd
}
func flexIDMap(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
func cmdCRMDeals() *cobra.Command {
var stages bool
cmd := &cobra.Command{
Use: "crm-deals",
Short: "List opportunities / stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stages {
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printJSON(out)
return nil
}
list, total, err := c.ListOpportunities(cmd.Context(), 100, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
cmd.Flags().BoolVar(&stages, "stages", false, "")
return cmd
}
func cmdCRMAddDeal() *cobra.Command {
var stage int
var bid float64
var contacts []string
cmd := &cobra.Command{
Use: "crm-add-deal TITLE",
Short: "Create opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, "", "EUR", "", bid)
if err != nil {
return err
}
did := fmt.Sprint(int(flexIDMap(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printJSON(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "")
cmd.Flags().Float64Var(&bid, "bid", 0, "")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id (repeatable)")
return cmd
}
func cmdCRMCases() *cobra.Command {
return &cobra.Command{
Use: "crm-cases",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), 50, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
}
func cmdAppsSync() *cobra.Command {
var apply, verbose bool
var base string
cmd := &cobra.Command{
Use: "applications-sync",
Short: "Sync applications/*/README.md into CRM",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if base == "" {
return fmt.Errorf("use --path to applications year dir (e.g. .../applications/2026)")
}
paths, err := applications.Discover(base)
if err != nil {
return err
}
var apps []applications.Data
for _, p := range paths {
a, err := applications.ParseReadme(p)
if err != nil {
return err
}
if a.Position == "" && a.Company == "" {
continue
}
apps = append(apps, a)
}
fmt.Printf("Found %d application(s)\n", len(apps))
applications.Sync(cmd.Context(), c, apps, !apply, verbose)
return nil
},
}
cmd.Flags().BoolVar(&apply, "apply", false, "write to CRM (default dry-run)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
cmd.Flags().StringVar(&base, "path", "", "applications base directory")
return cmd
}
+238
View File
@@ -0,0 +1,238 @@
package onlyoffice
// OnlyOffice Workspace Mail addon (/addons/mail) — list, read, and remove
// messages for the mailbox bound to the authenticated portal user.
import (
"context"
"fmt"
"net/mail"
"net/url"
"strconv"
"strings"
)
// Mail folder IDs in OnlyOffice Workspace (standard mailboxes).
const (
MailFolderInbox = 1
MailFolderSent = 2
MailFolderDrafts = 3
MailFolderTrash = 4
MailFolderSpam = 5
)
// MailMessagesFilter selects messages from GET /api/2.0/mail/messages.
// The API returns at most 25 messages per request; ListMailMessages paginates
// automatically when Count exceeds that or StartIndex is non-zero.
type MailMessagesFilter struct {
Folder int // folder id (default inbox when zero)
Count int // max messages to return (0 → one API page)
StartIndex int // skip this many messages before collecting Count
}
const mailMessagesPageSize = 25
// ListMailAccounts returns mailboxes linked to the current user.
func (c *Client) ListMailAccounts(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/mail/accounts")
}
// ListMailFolders returns folder counters (id, unread, total_count, …).
func (c *Client) ListMailFolders(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/mail/folders")
}
// ListMailMessages returns messages matching the filter.
func (c *Client) ListMailMessages(ctx context.Context, f MailMessagesFilter) ([]map[string]any, error) {
want := f.Count
if want <= 0 {
want = mailMessagesPageSize
}
if want <= mailMessagesPageSize && f.StartIndex == 0 {
return c.ResponseArray(ctx, mailMessagesPath(f, 1, want))
}
var out []map[string]any
toSkip := f.StartIndex
page := 1
for len(out) < want {
chunk, err := c.ResponseArray(ctx, mailMessagesPath(f, page, mailMessagesPageSize))
if err != nil {
return nil, err
}
if len(chunk) == 0 {
break
}
if toSkip > 0 {
if toSkip >= len(chunk) {
toSkip -= len(chunk)
page++
continue
}
chunk = chunk[toSkip:]
toSkip = 0
}
pageLen := len(chunk)
need := want - len(out)
if len(chunk) > need {
chunk = chunk[:need]
}
out = append(out, chunk...)
if pageLen < mailMessagesPageSize {
break
}
page++
}
return out, nil
}
// GetMailMessage returns one message by numeric id.
func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[string]any, error) {
id := strings.TrimSpace(messageID)
if id == "" {
return nil, fmt.Errorf("GetMailMessage: message id is required")
}
return c.ResponseObject(ctx, "/api/2.0/mail/messages/"+url.PathEscape(id))
}
// RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove).
func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) {
if len(ids) == 0 {
return nil, fmt.Errorf("RemoveMailMessages: at least one id is required")
}
return c.putJSONObject(ctx, "/api/2.0/mail/messages/remove", map[string]any{"ids": ids})
}
// ResolveMailFolder maps a CLI folder name or numeric string to a folder id.
// Empty input defaults to inbox (1).
func ResolveMailFolder(name string) (int, error) {
s := strings.TrimSpace(strings.ToLower(name))
if s == "" {
return MailFolderInbox, nil
}
if n, err := strconv.Atoi(s); err == nil && n > 0 {
return n, nil
}
switch s {
case "inbox":
return MailFolderInbox, nil
case "sent":
return MailFolderSent, nil
case "drafts", "draft":
return MailFolderDrafts, nil
case "trash":
return MailFolderTrash, nil
case "spam":
return MailFolderSpam, nil
default:
return 0, fmt.Errorf("unknown mail folder %q (use inbox|sent|drafts|trash|spam or numeric id)", name)
}
}
func mailMessagesPath(f MailMessagesFilter, page, count int) string {
q := url.Values{}
folder := f.Folder
if folder <= 0 {
folder = MailFolderInbox
}
q.Set("folder", strconv.Itoa(folder))
if count > 0 {
q.Set("count", strconv.Itoa(count))
}
if page > 1 {
q.Set("page", strconv.Itoa(page))
}
return "/api/2.0/mail/messages?" + q.Encode()
}
// ParseMailAddress splits a RFC 5322 mailbox string into display name and email.
// Examples:
// - `"LinkedIn" <a@b.com>` → name LinkedIn, address a@b.com
// - `eslider@gmail.com` → address only
func ParseMailAddress(raw string) (name, address string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
a, err := mail.ParseAddress(raw)
if err != nil {
return "", raw
}
return a.Name, a.Address
}
// MailMessagesAsTableRows converts list results for printTable.
func MailMessagesAsTableRows(msgs []map[string]any) []map[string]any {
rows := make([]map[string]any, len(msgs))
for i, m := range msgs {
fromName, fromAddress := ParseMailAddress(stringFromMap(m, "from"))
rows[i] = map[string]any{
"id": idFromMap(m, "id"),
"subject": stringFromMap(m, "subject"),
"fromName": fromName,
"fromAddress": fromAddress,
"date": stringFromMap(m, "date"),
"folder": idFromMap(m, "folder"),
"size": idFromMap(m, "size"),
"isNew": boolFromMap(m, "isNew"),
}
}
return rows
}
// MailAccountsAsTableRows converts account list results for printTable.
func MailAccountsAsTableRows(accounts []map[string]any) []map[string]any {
rows := make([]map[string]any, len(accounts))
for i, a := range accounts {
rows[i] = map[string]any{
"mailboxId": idFromMap(a, "mailboxId"),
"email": stringFromMap(a, "email"),
"enabled": boolFromMap(a, "enabled"),
"isDefault": boolFromMap(a, "isDefault"),
}
}
return rows
}
// MailFoldersAsTableRows converts folder list results for printTable.
func MailFoldersAsTableRows(folders []map[string]any) []map[string]any {
rows := make([]map[string]any, len(folders))
for i, f := range folders {
rows[i] = map[string]any{
"id": idFromMap(f, "id"),
"unread": idFromMap(f, "unread"),
"total_count": idFromMap(f, "total_count"),
"time_modified": stringFromMap(f, "time_modified"),
}
}
return rows
}
func idFromMap(m map[string]any, key string) string {
switch v := m[key].(type) {
case float64:
return strconv.FormatInt(int64(v), 10)
case int:
return strconv.Itoa(v)
case string:
return v
default:
return fmt.Sprint(m[key])
}
}
func stringFromMap(m map[string]any, key string) string {
if s, ok := m[key].(string); ok {
return s
}
return fmt.Sprint(m[key])
}
func boolFromMap(m map[string]any, key string) string {
switch v := m[key].(type) {
case bool:
return strconv.FormatBool(v)
default:
return fmt.Sprint(m[key])
}
}
+41
View File
@@ -0,0 +1,41 @@
//go:build integration
package onlyoffice
import (
"context"
"testing"
)
func TestIntegrationMailList(t *testing.T) {
creds := GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
t.Skip("ONLYOFFICE_URL/USER/PASS not set")
}
c := NewClient(creds)
ctx := context.Background()
if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("auth: %v", err)
}
accounts, err := c.ListMailAccounts(ctx)
if err != nil {
t.Fatalf("ListMailAccounts: %v", err)
}
t.Logf("accounts=%d", len(accounts))
msgs, err := c.ListMailMessages(ctx, MailMessagesFilter{Folder: MailFolderInbox, Count: 3})
if err != nil {
t.Fatalf("ListMailMessages: %v", err)
}
if len(msgs) == 0 {
t.Skip("no inbox messages to inspect")
}
id := idFromMap(msgs[0], "id")
msg, err := c.GetMailMessage(ctx, id)
if err != nil {
t.Fatalf("GetMailMessage(%s): %v", id, err)
}
if msg["subject"] == nil {
t.Fatalf("message missing subject: %+v", msg)
}
}
+72
View File
@@ -0,0 +1,72 @@
package onlyoffice
import "testing"
func TestResolveMailFolder(t *testing.T) {
tests := []struct {
in string
want int
err bool
}{
{"", MailFolderInbox, false},
{"inbox", MailFolderInbox, false},
{"SENT", MailFolderSent, false},
{"4", MailFolderTrash, false},
{"nope", 0, true},
}
for _, tc := range tests {
got, err := ResolveMailFolder(tc.in)
if tc.err {
if err == nil {
t.Fatalf("%q: expected error", tc.in)
}
continue
}
if err != nil || got != tc.want {
t.Fatalf("%q: got %d err=%v", tc.in, got, err)
}
}
}
func TestMailMessagesPath(t *testing.T) {
path := mailMessagesPath(MailMessagesFilter{Folder: 1}, 1, 10)
if path != "/api/2.0/mail/messages?count=10&folder=1" {
t.Fatalf("page 1: got %q", path)
}
path = mailMessagesPath(MailMessagesFilter{Folder: 1}, 3, 25)
if path != "/api/2.0/mail/messages?count=25&folder=1&page=3" {
t.Fatalf("page 3: got %q", path)
}
}
func TestParseMailAddress(t *testing.T) {
tests := []struct {
raw string
wantName string
wantAddress string
}{
{`"LinkedIn Jobbenachrichtigungen" <jobalerts-noreply@linkedin.com>`, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"},
{`"Bitfinex" <no-reply@bitfinex.com>`, "Bitfinex", "no-reply@bitfinex.com"},
{"eslider@gmail.com", "", "eslider@gmail.com"},
{`"Glassdoor-Jobs" <noreply@glassdoor.com>`, "Glassdoor-Jobs", "noreply@glassdoor.com"},
{"", "", ""},
}
for _, tc := range tests {
name, addr := ParseMailAddress(tc.raw)
if name != tc.wantName || addr != tc.wantAddress {
t.Fatalf("%q: got name=%q addr=%q", tc.raw, name, addr)
}
}
}
func TestMailMessagesAsTableRows(t *testing.T) {
msgs := []map[string]any{{
"id": float64(42), "subject": "Hi",
"from": `"Acme" <a@b.com>`,
"date": "today", "folder": float64(1), "size": float64(100), "isNew": true,
}}
rows := MailMessagesAsTableRows(msgs)
if rows[0]["id"] != "42" || rows[0]["fromName"] != "Acme" || rows[0]["fromAddress"] != "a@b.com" {
t.Fatalf("got %+v", rows[0])
}
}
-686
View File
@@ -1,686 +0,0 @@
package onlyoffice
// OnlyOffice client package
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/google/go-querystring/query"
)
// NewClient API
func NewClient(c Credentials) *Client {
return &Client{
client: http.DefaultClient,
credentials: &c,
}
}
// Defaults holds optional fallbacks used by package-level helpers when callers
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults.
type Defaults struct {
CalendarID string
ProjectID string
}
// SetDefaults configures optional identifiers used as fallbacks by methods such
// as AddEvent (when calendarID == "") or AddTask (when projectID == "").
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
// firstNonEmpty returns the first non-empty trimmed value, or "" if none found.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
//
// Primary variables (documented):
// - ONLYOFFICE_URL
// - ONLYOFFICE_USER
// - ONLYOFFICE_PASS
//
// Additional aliases accepted for interoperability with sibling tools:
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
func GetEnvironmentCredentials() Credentials {
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
url = strings.TrimRight(url, "/")
return Credentials{
Url: url,
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
}
}
// GetEnvironmentDefaults reads optional library defaults from environment:
//
// - ONLYOFFICE_CALENDAR_ID (default: "1")
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
func GetEnvironmentDefaults() Defaults {
return Defaults{
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
ProjectID: firstNonEmpty(
os.Getenv("ONLYOFFICE_PROJECT_ID"),
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
"33",
),
}
}
// Credentials of OnlyOffice User
type Credentials struct {
Url string `json:"-"`
User string `json:"userName"`
Password string `json:"password"`
}
// ToJson for Credentials
func (c Credentials) ToJson() []byte {
b, err := json.Marshal(c)
if err != nil {
return nil
}
return b
}
// Client of OnlyOffice API uses credentials to get a token and query the API by every request
type Client struct {
client *http.Client // HTTP client
credentials *Credentials // OnlyOffice credentials
token *Token // Authentication token
defaults Defaults // optional fallbacks for calendar/project IDs
selfID string // cached /api/2.0/people/@self id
noteCatID int // cached CRM history category id for "note"
}
// MetaResponse Response
type MetaResponse struct {
Count int `json:"count"`
Total int `json:"total"`
Status int `json:"status"`
StatusCode int `json:"statusCode"`
}
type Permissions struct {
CanEdit *bool `json:"canEdit,omitempty"` // Is user can edit
CanDelete *bool `json:"canDelete,omitempty"` // Is user can delete
}
// Project struct
type Project struct {
ID *int `json:"id"`
Title *string `json:"title"`
Security map[string]bool `json:"security,omitempty"`
ProjectFolder *json.Number `json:"projectFolder,omitempty"`
Description *string `json:"description"`
Status *int `json:"status"`
ResponsibleID *string `json:"responsibleId,omitempty"`
Responsible *User `json:"responsible,omitempty"`
IsPrivate *bool `json:"isPrivate"`
TaskCount *int `json:"taskCount,omitempty"`
TaskCountTotal *int `json:"taskCountTotal,omitempty"`
MilestoneCount *int `json:"milestoneCount,omitempty"`
DiscussionCount *int `json:"discussionCount,omitempty"`
ParticipantCount *int `json:"participantCount,omitempty"`
TimeTrackingTotal *string `json:"timeTrackingTotal,omitempty"`
DocumentsCount *int `json:"documentsCount,omitempty"`
IsFollow *bool `json:"isFollow,omitempty"`
Created *time.Time `json:"created"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById"`
Updated *time.Time `json:"updated"`
UpdatedByID *string `json:"updatedById"`
Permissions *Permissions `json:",inline,omitempty"`
}
type Milestone struct {
ID *int64 `json:"id,omitempty"`
Description *string `json:"description,omitempty"`
Title *string `json:"title,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
IsKey *bool `json:"isKey,omitempty"`
IsNotify *bool `json:"isNotify,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Responsible *User `json:"responsible,omitempty"`
ActiveTaskCount *int64 `json:"activeTaskCount,omitempty"`
ClosedTaskCount *int64 `json:"closedTaskCount,omitempty"`
Status *int64 `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
*Permissions `json:",inline,omitempty"`
}
type User struct {
ID *string `json:"id,omitempty"`
UserName *string `json:"userName,omitempty"`
IsVisitor *bool `json:"isVisitor,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Email *string `json:"email,omitempty"`
Status *int `json:"status,omitempty"`
ActivationStatus *int `json:"activationStatus,omitempty"`
Terminated any `json:"terminated,omitempty"`
Department *string `json:"department,omitempty"`
WorkFrom *time.Time `json:"workFrom,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
AvatarMedium *string `json:"avatarMedium,omitempty"`
Avatar *string `json:"avatar,omitempty"`
IsAdmin *bool `json:"isAdmin,omitempty"`
IsLDAP *bool `json:"isLDAP,omitempty"`
ListAdminModules []string `json:"listAdminModules,omitempty"`
IsOwner *bool `json:"isOwner,omitempty"`
CultureName *string `json:"cultureName,omitempty"`
IsSSO *bool `json:"isSSO,omitempty"`
AvatarSmall *string `json:"avatarSmall,omitempty"`
QuotaLimit *int `json:"quotaLimit,omitempty"`
UsedSpace *int `json:"usedSpace,omitempty"`
DocsSpace *int `json:"docsSpace,omitempty"`
MailSpace *int `json:"mailSpace,omitempty"`
TalkSpace *int `json:"talkSpace,omitempty"`
ProfileURL *string `json:"profileUrl,omitempty"`
Title *string `json:"title,omitempty"`
Sex *string `json:"sex,omitempty"`
Lead *string `json:"lead,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
Location *string `json:"location,omitempty"`
Notes *string `json:"notes,omitempty"`
Contacts []Contact `json:"contacts,omitempty"`
Groups []Group `json:"groups,omitempty"`
}
type Contact struct {
Type *string `json:"type,omitempty"`
Value *string `json:"value,omitempty"`
}
type Group struct {
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
Manager interface{} `json:"manager,omitempty"`
}
type Task struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Subtasks []any `json:"subtasks,omitempty"`
Status *ProjectTaskStatus `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById,omitempty"` // UUID
Updated *time.Time `json:"updated,omitempty"`
UpdatedBy *User `json:"updatedBy,omitempty"`
UpdatedById *string `json:"updatedById,omitempty"` // UUID
Responsibles []*User `json:"responsibles,omitempty"`
ResponsibleIDS []string `json:"responsibleIds,omitempty"` // UUID list
CanEdit *bool `json:"canEdit,omitempty"`
CanCreateSubtask *bool `json:"canCreateSubtask,omitempty"`
CanCreateTimeSpend *bool `json:"canCreateTimeSpend,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
CanReadFiles *bool `json:"canReadFiles,omitempty"`
MilestoneID *int64 `json:"milestoneId,omitempty"`
Milestone *Milestone `json:"milestone,omitempty"`
}
var GiteaIssue2OnlyOfficeMappingRegExp = regexp.MustCompile(`URL:(.*)$`)
var TestReg = `
URL:https://git.markets-platform.com/TradePlatform/email-templates/issues/1`
// GetGiteaIssueLink from task description
func (t *Task) GetGiteaIssueLink() string {
// Get issue from description
if t.Description != nil {
var match = GiteaIssue2OnlyOfficeMappingRegExp.FindStringSubmatch(*t.Description)
if len(match) > 1 {
return strings.TrimSpace(match[1])
}
}
return ""
}
// TaskPriority High = 1, Normal = 0, Low = -1
type TaskPriority int
const (
TaskPriorityHigh TaskPriority = 1 // High
TaskPriorityNormal TaskPriority = 0 // Normal
TaskPriorityLow TaskPriority = -1
)
type ProjectOwner struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Status *int `json:"status,omitempty"`
IsPrivate *bool `json:"isPrivate,omitempty"`
}
// String for Project to return title
func (p Project) String() string {
if p.Title == nil {
return ""
}
return *p.Title
}
// Token OnlyOffice
type Token struct {
Value string `json:"token"`
Expires Time `json:"expires"`
}
// Time for OnlyOffice
type Time time.Time
// To String for Time
func (t Time) String() string {
return time.Time(t).Format("2006-01-02T15:04:05")
}
func (t Time) Before(u Time) bool {
return time.Time(t).Before(time.Time(u))
}
func (t Time) After(u Time) bool {
return time.Time(t).After(time.Time(u))
}
// UnmarshalJSON for Time
func (r *Time) UnmarshalJSON(data []byte) error {
// Trim quotes
data = data[1 : len(data)-1]
t, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", string(data))
if err != nil {
return err
}
*r = Time(t)
return nil
}
// Request for OnlyOffice API
type Request struct {
Uri string // URI is the path to the API endpoint e.g. /api/2.0/project.json
Method string // Method is the HTTP method e.g. GET, POST, PUT, DELETE
Params any // Params is the query parameters
Body any // Body could be a struct, map, []byte, or string. It will be marshaled to JSON
Token *string // Token is the Authorization header, if not set then it will get a token
NoAuth bool // NoAuth for skipping automatic authentication
Debug bool
}
// GetMethod for Request
func (r Request) GetMethod() string {
if r.Method == "" {
return "GET"
}
return r.Method
}
// Query the OnlyOffice API
// - If request.Method is not set then it will default to GET
// - If request.Body is not nil then it will be marshaled to JSON (unless already []byte/string)
// - If request.Token is nil then it will get (or reuse) a cached token
// - If request.Token is not nil then it will be used as Authorization header
// - If request.NoAuth is true then it will skip automatic authentication
//
// The Debug field is preserved for backwards compatibility; it no longer
// changes behaviour — both branches used to unmarshal into the same target.
func (c *Client) Query(request Request, result interface{}) error {
url := c.credentials.Url + request.Uri
if request.Params != nil {
v, err := query.Values(request.Params)
if err != nil {
return err
}
url = fmt.Sprintf("%s?%s", url, v.Encode())
}
rdr, err := requestBodyReader(request.Body)
if err != nil {
return err
}
req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache")
if !request.NoAuth {
if err := c.ensureToken(); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
}
switch {
case request.Token != nil:
req.Header.Set("Authorization", *request.Token)
case c.token != nil:
req.Header.Set("Authorization", c.token.Value)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if result == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(result)
}
// requestBodyReader normalises Query() body input into an io.Reader.
// []byte and string are passed through verbatim; everything else is marshalled
// to JSON. Returns (nil, nil) for a nil body.
func requestBodyReader(body any) (io.Reader, error) {
if body == nil {
return nil, nil
}
switch b := body.(type) {
case []byte:
return bytes.NewReader(b), nil
case string:
return strings.NewReader(b), nil
default:
j, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
return bytes.NewReader(j), nil
}
}
// Auth to authenticate by getting a token using credentials
func (c *Client) Auth(creds *Credentials) (t *Token, err error) {
t = &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: "POST",
Body: creds,
NoAuth: true,
}, &struct { // Unmarshal response into a struct
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Projects list
type Projects []*Project
// Get Project by title
func (p Projects) Get(title string) *Project {
for _, prj := range p {
if *prj.Title == title {
return prj
}
}
return nil
}
// GetProjects
// - Get all projects, not excluding private projects
func (c *Client) GetProjects() (list Projects, err error) {
return list, c.Query(Request{Uri: `/api/2.0/project/filter.json?simple=true`},
&struct {
MetaResponse `json:",inline"`
Response *Projects
}{Response: &list})
}
// GetProjectMilestones Get project milestones
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID)},
&struct {
MetaResponse `json:",inline"`
//Response *[]*Milestone
Response *[]*Milestone
}{Response: &list})
return list, err
}
// GetUsers List users
func (c *Client) GetUsers() (list []*User, err error) {
return list, c.Query(Request{Uri: "/api/2.0/people/filter.json"},
&struct {
MetaResponse `json:",inline"`
Response *[]*User `json:"response"`
}{Response: &list})
}
// NewProjectRequest
type NewProjectRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ResponsibleID string `json:"responsibleId"`
}
// ProjectUpdateRequest
type ProjectUpdateRequest struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ResponsibleID string `json:"responsibleId,omitempty"`
}
// NewProjectTaskRequest
type NewProjectTaskRequest struct {
Title string `url:"title"`
Description string `url:"description"`
Notify bool `url:"notify"`
MilestoneId int `url:"milestoneId"`
Priority int `url:"priority"`
ProjectId int `url:"projectId"`
StartDate Time `url:"startDate"` // 2024-04-16T00:00:00
Deadline Time `url:"deadline"` // 2024-04-20T00:00:00
Status ProjectTaskStatus
}
type ProjectTaskStatus int
const (
ProjectTaskStatusNotAccept ProjectTaskStatus = 0
ProjectTaskStatusOpen ProjectTaskStatus = 1
ProjectTaskStatusClosed ProjectTaskStatus = 2
ProjectTaskStatusDisable ProjectTaskStatus = 3
ProjectTaskStatusUnclassified ProjectTaskStatus = 4
ProjectTaskStatusNotInMilestone ProjectTaskStatus = 5
)
// ProjectTaskUpdateRequest - Update a task
// See: https://api1.onlyoffice.com/portals/method/project/put/api/2.0/project/task/%7btaskid%7d
type ProjectTaskUpdateRequest struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"` // optional, New task priority {"0"} High = 1, Normal = 0, Low = -1
StartDate *Time `json:"startDate,omitempty"`
Deadline *Time `json:"deadline,omitempty"`
//Deadline *Time `json:"closed,omitempty"`
ProjectID *int64 `json:"projectID,omitempty"` // New task project ID (optional)
MilestoneId *int64 `json:"milestoneid,omitempty"`
Responsible []string `json:"responsibles,omitempty"` // New list of task responsibles {"9924256A-739C-462b-AF15-E652A3B1B6EB"}
Notify bool `json:"notify,omitempty"`
Status ProjectTaskStatus `json:"status,omitempty"`
//Progress int64 `json:"progress,omitempty"` // New task progress
}
// MarshalJSON to quote Time as string
func (t Time) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(
`"%s"`,
time.Time(t).Format("2006-01-02T15:04:05"),
)), nil
}
// CreateProject with a new project request
// - if responsibleID is empty then it will use the first user as responsible
// - if responsibleID is empty and user is not found then it will use the current user
func (c *Client) CreateProject(np NewProjectRequest) (prj *Project, err error) {
// We need a responsible user
if np.ResponsibleID == "" {
var users []*User
users, err = c.GetUsers()
if err != nil {
return
}
// Find user by email
for _, user := range users {
if *user.Email == c.credentials.User {
np.ResponsibleID = *user.ID
break
}
}
// Has no user?
if np.ResponsibleID == "" {
// Set first user as responsible
np.ResponsibleID = *users[0].ID
}
}
prj = new(Project)
return prj, c.Query(Request{
Uri: "/api/2.0/project.json",
Method: "POST",
Body: np,
}, &struct {
MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{
Response: prj,
})
}
func (c *Client) CreateProjectTask(req NewProjectTaskRequest) (task *Task, err error) {
task = &Task{}
return task, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/task.json", req.ProjectId),
Method: "POST",
Body: req,
}, &struct {
//MetaResponse `json:",inline"`
Response *Task `json:"response"`
}{task})
}
func (c *Client) DeleteProject(id int) (p *Project, err error) {
p = &Project{}
return p, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", id),
Method: "DELETE"},
&struct {
//MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{p})
}
func (c *Client) UpdateProject(req ProjectUpdateRequest) (p *Project, err error) {
p = &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
Method: "PUT",
Body: req,
},
&struct {
//MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{p})
}
func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (task *Task, err error) {
task = &Task{}
return task, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT",
Body: req,
}, &struct {
Response *Task `json:"response"`
}{task})
}
// ProjectGetTasksRequest
// See: https://api.onlyoffice.com/workspace/api-backend/usage-api/project/tasks/get-tasks-by-status/
type ProjectGetTasksRequest struct {
ProjectId int `url:"projectId"`
Count int `url:"count"`
StartIndex int `url:"startIndex"`
SortBy string `url:"sortBy"`
SortOrder string `url:"sortOrder"`
Simple bool `url:"simple"`
}
// NewProjectGetTasksRequest to get a simple task list
func NewProjectGetTasksRequest(projectId int) ProjectGetTasksRequest {
return ProjectGetTasksRequest{
ProjectId: projectId,
Count: 1000,
StartIndex: 0,
SortBy: "title",
SortOrder: "ascending",
Simple: true,
}
}
// GetTasks returns a list of all the tasks from a project with the ID specified in the request.
func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error) {
return tasks, c.Query(
Request{
Uri: "/api/2.0/project/task/filter.json",
Params: req,
},
&struct {
Response *[]*Task `json:"response"`
}{&tasks})
}
+206
View File
@@ -0,0 +1,206 @@
package onlyoffice
// Project / Milestone typed API.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"time"
)
// Project struct
type Project struct {
ID *int `json:"id"`
Title *string `json:"title"`
Security map[string]bool `json:"security,omitempty"`
ProjectFolder *json.Number `json:"projectFolder,omitempty"`
Description *string `json:"description"`
Status *int `json:"status"`
ResponsibleID *string `json:"responsibleId,omitempty"`
Responsible *User `json:"responsible,omitempty"`
IsPrivate *bool `json:"isPrivate"`
TaskCount *int `json:"taskCount,omitempty"`
TaskCountTotal *int `json:"taskCountTotal,omitempty"`
MilestoneCount *int `json:"milestoneCount,omitempty"`
DiscussionCount *int `json:"discussionCount,omitempty"`
ParticipantCount *int `json:"participantCount,omitempty"`
TimeTrackingTotal *string `json:"timeTrackingTotal,omitempty"`
DocumentsCount *int `json:"documentsCount,omitempty"`
IsFollow *bool `json:"isFollow,omitempty"`
Created *time.Time `json:"created"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById"`
Updated *time.Time `json:"updated"`
UpdatedByID *string `json:"updatedById"`
Permissions *Permissions `json:",inline,omitempty"`
}
// String returns the project Title, or "" if the Title is nil.
func (p Project) String() string {
if p.Title == nil {
return ""
}
return *p.Title
}
// Projects is a slice with helpers for title lookup.
type Projects []*Project
// Get returns the first project whose title equals title, or nil.
func (p Projects) Get(title string) *Project {
for _, prj := range p {
if prj != nil && prj.Title != nil && *prj.Title == title {
return prj
}
}
return nil
}
// Milestone is a project milestone.
type Milestone struct {
ID *int64 `json:"id,omitempty"`
Description *string `json:"description,omitempty"`
Title *string `json:"title,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
IsKey *bool `json:"isKey,omitempty"`
IsNotify *bool `json:"isNotify,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Responsible *User `json:"responsible,omitempty"`
ActiveTaskCount *int64 `json:"activeTaskCount,omitempty"`
ClosedTaskCount *int64 `json:"closedTaskCount,omitempty"`
Status *int64 `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
*Permissions `json:",inline,omitempty"`
}
// ProjectOwner is a compact project reference used by Milestone/Task.
type ProjectOwner struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Status *int `json:"status,omitempty"`
IsPrivate *bool `json:"isPrivate,omitempty"`
}
// NewProjectRequest is the payload for CreateProject.
type NewProjectRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ResponsibleID string `json:"responsibleId"`
}
// ProjectUpdateRequest is the payload for UpdateProject. Only non-empty
// fields are transmitted (enforced by omitempty).
type ProjectUpdateRequest struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ResponsibleID string `json:"responsibleId,omitempty"`
}
// GetProjects returns all projects, including private ones the caller can see.
func (c *Client) GetProjects() (list Projects, err error) {
return list, c.Query(Request{Uri: `/api/2.0/project/filter.json?simple=true`},
&struct {
MetaResponse `json:",inline"`
Response *Projects
}{Response: &list})
}
// GetProjectByID returns a single project as an untyped map. The typed
// counterpart is not currently provided; callers can iterate GetProjects and
// match by title or write their own typed wrapper.
//
// When projectID is empty the configured default is used.
func (c *Client) GetProjectByID(ctx context.Context, projectID string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID)))
}
// GetProjectMilestones returns milestones for the given project.
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID)},
&struct {
MetaResponse `json:",inline"`
Response *[]*Milestone
}{Response: &list})
return list, err
}
// CreateProject creates a new project.
// - if ResponsibleID is empty, the first user matching the client's User
// email is picked; failing that, the first portal user.
func (c *Client) CreateProject(np NewProjectRequest) (*Project, error) {
if np.ResponsibleID == "" {
users, err := c.GetUsers()
if err != nil {
return nil, err
}
for _, u := range users {
if u.Email != nil && *u.Email == c.credentials.User {
np.ResponsibleID = *u.ID
break
}
}
if np.ResponsibleID == "" && len(users) > 0 && users[0].ID != nil {
np.ResponsibleID = *users[0].ID
}
}
prj := new(Project)
return prj, c.Query(Request{
Uri: "/api/2.0/project.json",
Method: "POST",
Body: np,
}, &struct {
MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{
Response: prj,
})
}
// DeleteProject deletes a project by numeric ID.
func (c *Client) DeleteProject(id int) (*Project, error) {
p := &Project{}
return p, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", id),
Method: "DELETE",
},
&struct {
Response *Project `json:"response"`
}{p})
}
// UpdateProject updates project fields.
func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
p := &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
Method: "PUT",
Body: req,
},
&struct {
Response *Project `json:"response"`
}{p})
}
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "go",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"include-v-in-tag": true,
"include-component-in-tag": false,
"pull-request-title-pattern": "chore${scope}: release ${version}",
"changelog-path": "CHANGELOG.md",
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance Improvements" },
{ "type": "revert", "section": "Reverts" },
{ "type": "refactor", "section": "Code Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build System", "hidden": true },
{ "type": "ci", "section": "Continuous Integration", "hidden": true },
{ "type": "chore", "section": "Miscellaneous", "hidden": true },
{ "type": "style", "section": "Styles", "hidden": true }
],
"packages": {
".": {
"package-name": "go-onlyoffice"
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package onlyoffice
// Low-level Request/Query primitive used by the typed helpers in this
// package. Prefer the domain-specific methods (CreateProject, GetTasks, …)
// or the untyped helpers in http.go (ResponseArray, ResponseObject, …) — this
// file is kept for API compatibility and for rare callers that want to hit
// arbitrary endpoints with arbitrary Params/Body.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/google/go-querystring/query"
)
// Request for OnlyOffice API.
type Request struct {
Uri string // URI is the path to the API endpoint e.g. /api/2.0/project.json
Method string // HTTP method; defaults to GET when empty.
Params any // Params is serialised to the query string via go-querystring.
Body any // Body is marshalled to JSON unless it is []byte or string.
Token *string // Explicit Authorization header; overrides the cached token.
NoAuth bool // Skip automatic authentication.
Debug bool // Kept for backwards compatibility; no longer affects behaviour.
}
// GetMethod returns Method, defaulting to GET when unset.
func (r Request) GetMethod() string {
if r.Method == "" {
return http.MethodGet
}
return r.Method
}
// Query the OnlyOffice API.
//
// - If request.Method is empty it defaults to GET.
// - If request.Body is non-nil it is marshalled to JSON (unless it is
// already []byte or string, which are passed through verbatim).
// - If request.Token is nil the cached session token is used (and
// refreshed as needed unless NoAuth is set).
// - If request.Token is non-nil it is used verbatim as the Authorization
// header.
// - If request.NoAuth is true then no token is fetched — the caller is
// responsible for authenticating requests (used internally by Auth()).
func (c *Client) Query(request Request, result interface{}) error {
url := c.credentials.Url + request.Uri
if request.Params != nil {
v, err := query.Values(request.Params)
if err != nil {
return err
}
url = fmt.Sprintf("%s?%s", url, v.Encode())
}
rdr, err := requestBodyReader(request.Body)
if err != nil {
return err
}
req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache")
if !request.NoAuth {
if err := c.ensureToken(); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
}
switch {
case request.Token != nil:
req.Header.Set("Authorization", *request.Token)
case c.token != nil:
req.Header.Set("Authorization", c.token.Value)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if result == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(result)
}
// requestBodyReader normalises Query() body input into an io.Reader.
// []byte and string are passed through verbatim; everything else is
// marshalled to JSON. Returns (nil, nil) for a nil body.
func requestBodyReader(body any) (io.Reader, error) {
if body == nil {
return nil, nil
}
switch b := body.(type) {
case []byte:
return bytes.NewReader(b), nil
case string:
return strings.NewReader(b), nil
default:
j, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
return bytes.NewReader(j), nil
}
}
// ToJson for Credentials returns a JSON-encoded payload suitable for the
// authentication endpoint. Returns nil on marshal error (which never happens
// for this struct).
func (c Credentials) ToJson() []byte {
b, err := json.Marshal(c)
if err != nil {
return nil
}
return b
}
// MetaResponse is the shape of every envelope returned by the OnlyOffice API.
// It is embedded inline into the per-endpoint response structs via the
// `json:",inline"` convention used throughout this package.
type MetaResponse struct {
Count int `json:"count"`
Total int `json:"total"`
Status int `json:"status"`
StatusCode int `json:"statusCode"`
}
// Permissions is a common subset of boolean permissions embedded into
// several entity types.
type Permissions struct {
CanEdit *bool `json:"canEdit,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
}
// Token is the authentication token returned by /api/2.0/authentication.json.
type Token struct {
Value string `json:"token"`
Expires Time `json:"expires"`
}
// Time wraps time.Time with the OnlyOffice "yyyy-MM-ddTHH:mm:ss.fffffffzzz"
// wire format used by several endpoints.
type Time time.Time
// String formats the time in OnlyOffice's expected ISO-8601 variant (without
// fractional seconds or timezone).
func (t Time) String() string {
return time.Time(t).Format("2006-01-02T15:04:05")
}
// Before reports whether t is before u.
func (t Time) Before(u Time) bool {
return time.Time(t).Before(time.Time(u))
}
// After reports whether t is after u.
func (t Time) After(u Time) bool {
return time.Time(t).After(time.Time(u))
}
// UnmarshalJSON decodes the OnlyOffice wire format into Time.
func (r *Time) UnmarshalJSON(data []byte) error {
data = data[1 : len(data)-1] // trim surrounding quotes
t, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", string(data))
if err != nil {
return err
}
*r = Time(t)
return nil
}
// MarshalJSON emits the OnlyOffice-friendly short form.
func (t Time) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`"%s"`, time.Time(t).Format("2006-01-02T15:04:05"))), nil
}
+275
View File
@@ -0,0 +1,275 @@
package onlyoffice
// Project task API — both typed (Task + CreateProjectTask / UpdateProjectTask
// / GetTasks) and untyped form-endpoint helpers (AddTask, AddSubtask,
// UpdateTaskStatus, DeleteTask, ListTasks, …). The typed path mirrors the
// JSON responses; the form-encoded path mirrors the Python cv/bin/office
// reference and the OnlyOffice web UI.
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// Task is the JSON-mapped project task as returned by /project/task/*.
type Task struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Subtasks []any `json:"subtasks,omitempty"`
Status *ProjectTaskStatus `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById,omitempty"` // UUID
Updated *time.Time `json:"updated,omitempty"`
UpdatedBy *User `json:"updatedBy,omitempty"`
UpdatedById *string `json:"updatedById,omitempty"` // UUID
Responsibles []*User `json:"responsibles,omitempty"`
ResponsibleIDS []string `json:"responsibleIds,omitempty"` // UUID list
CanEdit *bool `json:"canEdit,omitempty"`
CanCreateSubtask *bool `json:"canCreateSubtask,omitempty"`
CanCreateTimeSpend *bool `json:"canCreateTimeSpend,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
CanReadFiles *bool `json:"canReadFiles,omitempty"`
MilestoneID *int64 `json:"milestoneId,omitempty"`
Milestone *Milestone `json:"milestone,omitempty"`
}
// TaskPriority values: High = 1, Normal = 0, Low = -1.
type TaskPriority int
const (
TaskPriorityHigh TaskPriority = 1
TaskPriorityNormal TaskPriority = 0
TaskPriorityLow TaskPriority = -1
)
// ProjectTaskStatus encodes OnlyOffice task status codes.
type ProjectTaskStatus int
const (
ProjectTaskStatusNotAccept ProjectTaskStatus = 0
ProjectTaskStatusOpen ProjectTaskStatus = 1
ProjectTaskStatusClosed ProjectTaskStatus = 2
ProjectTaskStatusDisable ProjectTaskStatus = 3
ProjectTaskStatusUnclassified ProjectTaskStatus = 4
ProjectTaskStatusNotInMilestone ProjectTaskStatus = 5
)
// GiteaIssue2OnlyOfficeMappingRegExp extracts a "URL:" footer pointing at a
// Gitea issue, used by external sync tooling (inventar-sync et al.).
var GiteaIssue2OnlyOfficeMappingRegExp = regexp.MustCompile(`URL:(.*)$`)
// GetGiteaIssueLink returns the first Gitea URL embedded in the task
// description via the "URL:<url>" convention, or "" if absent.
func (t *Task) GetGiteaIssueLink() string {
if t.Description == nil {
return ""
}
m := GiteaIssue2OnlyOfficeMappingRegExp.FindStringSubmatch(*t.Description)
if len(m) > 1 {
return strings.TrimSpace(m[1])
}
return ""
}
// NewProjectTaskRequest creates a new task via the typed JSON API.
type NewProjectTaskRequest struct {
Title string `url:"title"`
Description string `url:"description"`
Notify bool `url:"notify"`
MilestoneId int `url:"milestoneId"`
Priority int `url:"priority"`
ProjectId int `url:"projectId"`
StartDate Time `url:"startDate"`
Deadline Time `url:"deadline"`
Status ProjectTaskStatus
}
// ProjectTaskUpdateRequest updates an existing task.
// https://api1.onlyoffice.com/portals/method/project/put/api/2.0/project/task/%7btaskid%7d
type ProjectTaskUpdateRequest struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
StartDate *Time `json:"startDate,omitempty"`
Deadline *Time `json:"deadline,omitempty"`
ProjectID *int64 `json:"projectID,omitempty"`
MilestoneId *int64 `json:"milestoneid,omitempty"`
Responsible []string `json:"responsibles,omitempty"` // UUID list
Notify bool `json:"notify,omitempty"`
Status ProjectTaskStatus `json:"status,omitempty"`
}
// ProjectGetTasksRequest is the filter payload for GetTasks.
// See https://api.onlyoffice.com/workspace/api-backend/usage-api/project/tasks/get-tasks-by-status/
type ProjectGetTasksRequest struct {
ProjectId int `url:"projectId"`
Count int `url:"count"`
StartIndex int `url:"startIndex"`
SortBy string `url:"sortBy"`
SortOrder string `url:"sortOrder"`
Simple bool `url:"simple"`
}
// NewProjectGetTasksRequest builds a simple "all tasks, sorted by title"
// request pre-populated with sane defaults (count=1000).
func NewProjectGetTasksRequest(projectId int) ProjectGetTasksRequest {
return ProjectGetTasksRequest{
ProjectId: projectId,
Count: 1000,
StartIndex: 0,
SortBy: "title",
SortOrder: "ascending",
Simple: true,
}
}
// CreateProjectTask creates a project task via the typed JSON API.
func (c *Client) CreateProjectTask(req NewProjectTaskRequest) (*Task, error) {
task := &Task{}
return task, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/task.json", req.ProjectId),
Method: "POST",
Body: req,
}, &struct {
Response *Task `json:"response"`
}{task})
}
// UpdateProjectTask updates task fields via the typed JSON API.
func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (*Task, error) {
task := &Task{}
return task, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT",
Body: req,
}, &struct {
Response *Task `json:"response"`
}{task})
}
// GetTasks returns a list of tasks for a project matching the given filter.
func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error) {
return tasks, c.Query(
Request{
Uri: "/api/2.0/project/task/filter.json",
Params: req,
},
&struct {
Response *[]*Task `json:"response"`
}{&tasks})
}
// -----------------------------------------------------------------------------
// Untyped form-endpoint helpers (Python cv/bin/office parity, web UI parity)
// -----------------------------------------------------------------------------
// ListTasks lists tasks in a single project. When projectID is empty the
// configured default is used. status accepts "open"/"closed" or a numeric
// code.
func (c *Client) ListTasks(ctx context.Context, projectID, status string) ([]map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
if projectID == "" {
return nil, fmt.Errorf("ListTasks: projectID is required (pass explicitly or set via SetDefaults)")
}
path := fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID))
if code := taskStatusCode(status); code != "" {
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// ListAllTasks lists tasks across projects for the authenticated user.
func (c *Client) ListAllTasks(ctx context.Context, status string) ([]map[string]any, error) {
path := "/api/2.0/project/task/@self.json"
if code := taskStatusCode(status); code != "" {
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// taskStatusCode maps "open"/"closed" to their numeric codes, passes any
// other non-empty value through verbatim, and returns "" for an empty input.
func taskStatusCode(status string) string {
switch status {
case "":
return ""
case "open":
return "0"
case "closed":
return "1"
default:
return status
}
}
// GetTaskByID returns a task as an untyped map, including its subtasks.
func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any, error) {
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
}
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
// Prefer the typed CreateProjectTask for new code; AddTask is kept for
// parity with the Python reference tooling.
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("description", description)
fields.Set("priority", strconv.Itoa(priority))
if deadline != "" {
fields.Set("deadline", deadline)
}
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
}
// AddSubtask creates a subtask under parentTaskID.
func (c *Client) AddSubtask(ctx context.Context, parentTaskID, title string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(parentTaskID)), fields)
}
// UpdateTaskStatus changes task status. status accepts "open"/"closed"
// (mapped to 1/2) or a raw numeric code passed through.
func (c *Client) UpdateTaskStatus(ctx context.Context, taskID, status string) (map[string]any, error) {
code := status
switch status {
case "open":
code = "1"
case "closed":
code = "2"
}
fields := url.Values{}
fields.Set("status", code)
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s/status.json", url.PathEscape(taskID)), fields)
}
// DeleteTask removes a project task by ID.
func (c *Client) DeleteTask(ctx context.Context, taskID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
}
-169
View File
@@ -1,169 +0,0 @@
package onlyoffice
// Task-related helpers that complement the typed Project/Task API in
// onlyoffice.go. These return untyped maps and use form-encoded endpoints,
// matching the Python cv/bin/office reference and the OnlyOffice web UI.
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
// GetProjectByID returns a single project as an untyped map.
// (The typed counterpart GetProject is not provided; callers that want typed
// structs should use Projects + iteration or add their own typed wrappers.)
func (c *Client) GetProjectByID(ctx context.Context, projectID string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// ListTasks lists tasks in a single project. When projectID is empty the
// configured default is used. status accepts "open"/"closed" or a numeric code.
func (c *Client) ListTasks(ctx context.Context, projectID, status string) ([]map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
if projectID == "" {
return nil, fmt.Errorf("ListTasks: projectID is required (pass explicitly or set via SetDefaults)")
}
path := fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID))
if status != "" {
code := map[string]string{"open": "0", "closed": "1"}[status]
if code == "" {
code = status
}
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// ListAllTasks lists tasks across projects for the authenticated user.
func (c *Client) ListAllTasks(ctx context.Context, status string) ([]map[string]any, error) {
path := "/api/2.0/project/task/@self.json"
if status != "" {
code := map[string]string{"open": "0", "closed": "1"}[status]
if code == "" {
code = status
}
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// GetTaskByID returns a task as an untyped map, including its subtasks.
func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any, error) {
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
// Prefer the typed CreateProjectTask for new code; AddTask is kept for parity
// with the Python reference tooling.
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("description", description)
fields.Set("priority", fmt.Sprintf("%d", priority))
if deadline != "" {
fields.Set("deadline", deadline)
}
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// AddSubtask creates a subtask under parentTaskID.
func (c *Client) AddSubtask(ctx context.Context, parentTaskID, title string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(parentTaskID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// UpdateTaskStatus changes task status. status accepts "open"/"closed" (mapped
// to 1/2) or a raw numeric code passed through.
func (c *Client) UpdateTaskStatus(ctx context.Context, taskID, status string) (map[string]any, error) {
code := map[string]string{"open": "1", "closed": "2"}[status]
if code == "" {
code = status
}
fields := url.Values{}
fields.Set("status", code)
raw, err := c.putForm(ctx, fmt.Sprintf("/api/2.0/project/task/%s/status.json", url.PathEscape(taskID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
}
// DeleteTask removes a project task by ID.
func (c *Client) DeleteTask(ctx context.Context, taskID string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
}
+25
View File
@@ -0,0 +1,25 @@
{
"count": 1,
"status": 0,
"statusCode": 200,
"response": {
"folders": [
{
"id": 10,
"title": "Subfolder",
"filesCount": 0,
"foldersCount": 0
}
],
"files": [
{
"id": 100,
"title": "readme.txt",
"fileExst": ".txt",
"contentLength": "12",
"fileType": 2,
"viewUrl": "/Products/Files/DocEditor.aspx?fileid=100"
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"count": 1,
"status": 0,
"statusCode": 200,
"response": [
{
"id": 200,
"title": "attach.pdf",
"fileExst": ".pdf",
"contentLength": "1024",
"fileType": 2
}
]
}
+93
View File
@@ -0,0 +1,93 @@
package onlyoffice
// User / People endpoints and associated entity types.
import (
"context"
"encoding/json"
"time"
)
// User represents an OnlyOffice portal user. Fields reflect the full
// /api/2.0/people/filter.json response; most are optional and returned
// only in user-detail responses.
type User struct {
ID *string `json:"id,omitempty"`
UserName *string `json:"userName,omitempty"`
IsVisitor *bool `json:"isVisitor,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Email *string `json:"email,omitempty"`
Status *int `json:"status,omitempty"`
ActivationStatus *int `json:"activationStatus,omitempty"`
Terminated any `json:"terminated,omitempty"`
Department *string `json:"department,omitempty"`
WorkFrom *time.Time `json:"workFrom,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
AvatarMedium *string `json:"avatarMedium,omitempty"`
Avatar *string `json:"avatar,omitempty"`
IsAdmin *bool `json:"isAdmin,omitempty"`
IsLDAP *bool `json:"isLDAP,omitempty"`
ListAdminModules []string `json:"listAdminModules,omitempty"`
IsOwner *bool `json:"isOwner,omitempty"`
CultureName *string `json:"cultureName,omitempty"`
IsSSO *bool `json:"isSSO,omitempty"`
AvatarSmall *string `json:"avatarSmall,omitempty"`
QuotaLimit *int `json:"quotaLimit,omitempty"`
UsedSpace *int `json:"usedSpace,omitempty"`
DocsSpace *int `json:"docsSpace,omitempty"`
MailSpace *int `json:"mailSpace,omitempty"`
TalkSpace *int `json:"talkSpace,omitempty"`
ProfileURL *string `json:"profileUrl,omitempty"`
Title *string `json:"title,omitempty"`
Sex *string `json:"sex,omitempty"`
Lead *string `json:"lead,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
Location *string `json:"location,omitempty"`
Notes *string `json:"notes,omitempty"`
Contacts []Contact `json:"contacts,omitempty"`
Groups []Group `json:"groups,omitempty"`
}
// Contact is a typed contact entry attached to a User.
type Contact struct {
Type *string `json:"type,omitempty"`
Value *string `json:"value,omitempty"`
}
// Group is a portal user group.
type Group struct {
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
Manager any `json:"manager,omitempty"`
}
// GetUsers lists all portal users.
func (c *Client) GetUsers() (list []*User, err error) {
return list, c.Query(Request{Uri: "/api/2.0/people/filter.json"},
&struct {
MetaResponse `json:",inline"`
Response *[]*User `json:"response"`
}{Response: &list})
}
// SelfUserID returns the ID of the authenticated user (people/@self), cached.
func (c *Client) SelfUserID(ctx context.Context) (string, error) {
if c.selfID != "" {
return c.selfID, nil
}
raw, err := c.getJSON(ctx, "/api/2.0/people/@self.json")
if err != nil {
return "", err
}
var env struct {
Response struct {
ID string `json:"id"`
} `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", err
}
c.selfID = env.Response.ID
return c.selfID, nil
}