Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed57a68d4 | ||
|
|
b2aae21390 | ||
|
|
3d4aa10cf9 | ||
|
|
3b47d1400b | ||
|
|
a413d409b5 | ||
|
|
cac0ffa2a0 | ||
|
|
cad68f64c6 | ||
|
|
4046f4c46a | ||
|
|
6d4df4edee | ||
|
|
3931eeaabb | ||
|
|
2959168d76 | ||
|
|
f8a73edef3 | ||
|
|
4acec6bbc9 | ||
|
|
2e77d8e5c7 | ||
|
|
87ce3257a8 | ||
|
|
a59b4fb296 | ||
|
|
b79c097874 | ||
|
|
1b238aca5a | ||
|
|
4eb14c082c | ||
|
|
0973975471 | ||
|
|
e2d481e7b8 | ||
|
|
2fc2adadd1 | ||
|
|
b63055d435 | ||
|
|
842e9aa132 | ||
|
|
0e70467522 |
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
|
||||
globs:
|
||||
- "**/*_test.go"
|
||||
alwaysApply: false
|
||||
---
|
||||
# Testing policy — no synthetic vendor mockups
|
||||
|
||||
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
|
||||
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
|
||||
other third-party API. Simulated vendor responses drift from reality, give
|
||||
false green signals, and hide protocol changes.
|
||||
|
||||
## What to do instead
|
||||
|
||||
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
|
||||
struct conversions, pure helpers. No `httptest` that fakes the vendor.
|
||||
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
|
||||
file. Read credentials from env:
|
||||
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
|
||||
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
|
||||
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
|
||||
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
|
||||
regular `go test ./...` stays green in CI.
|
||||
3. **Run integration**: `go test -tags=integration ./...`.
|
||||
4. **Every new endpoint** ships with an integration test in the same PR.
|
||||
|
||||
## Narrow exception
|
||||
|
||||
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
|
||||
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
|
||||
OK when the test server is pretending to be OnlyOffice or Gitea.
|
||||
|
||||
## Migrating existing tests
|
||||
|
||||
If you find a test that handles routes like `/api/2.0/...` and returns canned
|
||||
JSON, convert it to an integration test (or delete it if the behaviour is
|
||||
already covered by integration).
|
||||
@@ -0,0 +1,27 @@
|
||||
# OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
|
||||
# does not load .env files — only its CLI does, via godotenv).
|
||||
#
|
||||
# Primary names:
|
||||
ONLYOFFICE_URL=https://office.example.com
|
||||
ONLYOFFICE_USER=
|
||||
ONLYOFFICE_PASS=
|
||||
|
||||
# Accepted aliases (only one of each pair is needed):
|
||||
# ONLYOFFICE_HOST=
|
||||
# ONLYOFFICE_NAME=
|
||||
# ONLYOFFICE_PASSWORD=
|
||||
#
|
||||
# produktor.io operator aliases (CLI-only):
|
||||
# OO_URL=
|
||||
# OO_USER=
|
||||
# OO_PASS=
|
||||
|
||||
# Optional defaults for calendar/project helpers:
|
||||
ONLYOFFICE_CALENDAR_ID=1
|
||||
ONLYOFFICE_PROJECT_ID=33
|
||||
|
||||
# oo mails uses ONLYOFFICE_URL/USER/PASS above (Workspace Mail addon).
|
||||
|
||||
# cmd/office TUI — optional Document Server for DOCX→HTML preview:
|
||||
# ONLYOFFICE_DOCS_URL=https://docs.example.com
|
||||
# ONLYOFFICE_DOCS_SECRET=
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Local environment
|
||||
.env
|
||||
|
||||
# Go workspace (for local multi-module dev; see inventar ASR-0008)
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Build artifacts (top-level compiled binaries; cmd/oo/ source tree is tracked)
|
||||
/oo
|
||||
/oo-cli
|
||||
/bin/
|
||||
+124
@@ -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 }}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.6.0"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# AGENTS — go-onlyoffice
|
||||
|
||||
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo` command.
|
||||
|
||||
## Topology
|
||||
|
||||
- **Library — flat package `onlyoffice` at repo root.** Split by *domain file*, not by subpackage, so every call site reads `c.XxxYyy()` against a single `*Client`. Files:
|
||||
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
|
||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
||||
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go`, `mails.go` — 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
|
||||
|
||||
- Library must never call `godotenv.Load()` — the CLI does that.
|
||||
- New endpoints go into the library first; CLI commands are thin wrappers.
|
||||
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
|
||||
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
|
||||
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
|
||||
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
|
||||
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
|
||||
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||
|
||||
### Testing policy (2026-04-24)
|
||||
|
||||
**No synthetic OnlyOffice mockups.** Protocol-level behaviour must be
|
||||
verified against a real OnlyOffice instance. `httptest.NewServer` is only
|
||||
acceptable for testing the *caller's* logic that the library can't reach
|
||||
(for example, the user's own HTTP handler). Anywhere we would otherwise
|
||||
write `mux.HandleFunc("/api/2.0/...")` to emulate OnlyOffice, we write an
|
||||
**integration test** instead.
|
||||
|
||||
- Unit tests (`*_test.go`, no build tag) — pure Go: parsers, encoders,
|
||||
struct conversions. No network. No fake servers that emulate the vendor.
|
||||
- Integration tests (`//go:build integration` tag in `*_integration_test.go`)
|
||||
— hit a live OnlyOffice instance. Credentials come from `ONLYOFFICE_URL`,
|
||||
`ONLYOFFICE_USER`, `ONLYOFFICE_PASS` (aliases `_HOST`/`_NAME`/`_PASSWORD`
|
||||
also accepted). Tests **skip** cleanly when credentials are missing so
|
||||
`go test ./...` remains green in CI.
|
||||
- Run integration with: `go test -tags=integration ./...`.
|
||||
- New endpoints **must** ship with an integration test before merge.
|
||||
|
||||
## Related
|
||||
|
||||
- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions).
|
||||
- [`eSlider/inventar-sync`](https://git.produktor.io/eSlider/inventar-sync) — OnlyOffice → Gitea issue sync, consumes this library.
|
||||
- [`produktor.io/vidarr`](https://git.produktor.io/produktor.io/vidarr) — legacy consumer being migrated from `pkg/onlyoffice` to this module.
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format is based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** table detail panes and Alt+1/2/3 layout toggles ([934af21](https://github.com/eSlider/go-onlyoffice/commit/934af21bc93a54df9f40aacb4d89468b00d7562e))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* CHANGELOG for office v0.5.1 release ([2985070](https://github.com/eSlider/go-onlyoffice/commit/29850701f8c9ab0c6d6427f63eaa189a1c558140))
|
||||
|
||||
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **office:** scrollable panes, nav tree drill-down, and item actions ([fe2ee48](https://github.com/eSlider/go-onlyoffice/commit/fe2ee481b9c0f959b798fd40679084a8c4bc4ba2))
|
||||
|
||||
## [0.4.0](https://github.com/eSlider/go-onlyoffice/compare/v0.3.2...v0.4.0) (2026-06-24)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **cli:** subject-based command tree (tea-style) + global --output flag
|
||||
* rename oo-cli → oo, split library by domain, relocate applications
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** subject-based command tree (tea-style) + global --output flag ([1e6d22f](https://github.com/eSlider/go-onlyoffice/commit/1e6d22f38dd7a70d4cf1b69ef0b8749b01fded1a))
|
||||
* **crm:** dedupe duplicates, fix deal titles, and add cleanup CLI ([c77519f](https://github.com/eSlider/go-onlyoffice/commit/c77519fac5ae9dffaad3f1f0ae722d969e62dde9))
|
||||
* **crm:** merge company slogan variants in dedupe grouping ([3eb649c](https://github.com/eSlider/go-onlyoffice/commit/3eb649c58bf489b221f90734e04f33063f736914))
|
||||
* **files:** project/task Documents API + oo projects|tasks files ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
|
||||
* **mails:** add Workspace mail CLI with pagination and parsed from fields ([1cb228e](https://github.com/eSlider/go-onlyoffice/commit/1cb228e5d83ab453660bbace5f936a77f9381fc2))
|
||||
* **office:** add Workspace TUI with shared bootstrap and test suite ([7358ff5](https://github.com/eSlider/go-onlyoffice/commit/7358ff5c326cdc873cb68b3f96d31155ecba1ee3))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* rename oo-cli → oo, split library by domain, relocate applications ([cff8145](https://github.com/eSlider/go-onlyoffice/commit/cff8145f8c35656a77b7a36338428bf9be0eda0f))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree. ([e03fd62](https://github.com/eSlider/go-onlyoffice/commit/e03fd6220012c73b110f1e0b02a12971174eb290))
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.1] — 2026-06-24
|
||||
|
||||
### Added — `office` TUI
|
||||
|
||||
- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
|
||||
- Visible panes share **100% terminal width** evenly; pane content fills its column.
|
||||
- Tab focus skips hidden panes.
|
||||
|
||||
### Changed — `office` TUI
|
||||
|
||||
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
|
||||
- Middle pane: multi-column table with sort, selection, and full-width columns.
|
||||
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
|
||||
- Row selection auto-loads detail; files show document preview, entities show forms.
|
||||
|
||||
## [0.6.0] - 2026-04-24
|
||||
|
||||
### Added — library
|
||||
|
||||
- **Project & task Documents API** in [`files.go`](files.go):
|
||||
- `FileEntry`, `FolderEntry`, `ProjectFilesResponse` types.
|
||||
- `GetProjectFiles`, `GetTaskFiles`, `GetFile`.
|
||||
- `UploadProjectFile` — `POST /api/2.0/files/{folderId}/upload` into the
|
||||
project's `projectFolder` (resolved via `GetProjectByID` or first folder
|
||||
from `GetProjectFiles`).
|
||||
- `AttachFilesToTask` — `POST .../project/task/{id}/files` with form
|
||||
`files=<id>` (OnlyOffice expects **existing** file ids, not multipart).
|
||||
- `UploadTaskFile` — uploads via `UploadProjectFile` using the task's
|
||||
`projectOwner.id`, then attaches.
|
||||
- `DetachTaskFile` — `DELETE .../files?fileid=`.
|
||||
- `RenameFile` — `PUT /api/2.0/files/file/{id}.json` with JSON body.
|
||||
- `DeleteFiles` — `PUT /api/2.0/files/fileops/delete.json` with `fileIds`.
|
||||
- `DownloadFile` — `GetFile` then `GET` on `viewUrl` with `Authorization`.
|
||||
- Helpers: `FileEntryNumericID`, `FileEntryTitle`, `SafeLocalFileName`.
|
||||
- **`putJSON`** on `*Client` in [`http.go`](http.go) for JSON PUT bodies.
|
||||
|
||||
### Added — CLI
|
||||
|
||||
- `oo projects files list|upload|download|rename|delete` — see
|
||||
[`cmd/oo/projects_files.go`](cmd/oo/projects_files.go); `list` supports
|
||||
`--folders`.
|
||||
- `oo tasks files list|upload|detach` — see [`cmd/oo/tasks_files.go`](cmd/oo/tasks_files.go).
|
||||
|
||||
### Added — tests
|
||||
|
||||
- [`files_integration_test.go`](files_integration_test.go) — live roundtrip
|
||||
against OnlyOffice (same credential rules as `client_test.go`).
|
||||
- [`files_test.go`](files_test.go) + `testdata/*.json` — envelope decode unit
|
||||
tests (no network).
|
||||
|
||||
## [0.5.0] - 2026-04-24
|
||||
|
||||
### Changed — CLI **BREAKING**
|
||||
|
||||
- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun`
|
||||
layout is replaced with `oo <subject> <verb>`:
|
||||
|
||||
| Old | New |
|
||||
|---|---|
|
||||
| `oo cal-list` | `oo calendar list` |
|
||||
| `oo cal-events` | `oo calendar events` |
|
||||
| `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` |
|
||||
| `oo task-list` | `oo tasks list` |
|
||||
| `oo task-add` | `oo tasks create` |
|
||||
| `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) |
|
||||
| `oo subtask-add` | `oo tasks subtask add` |
|
||||
| `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) |
|
||||
| `oo crm-add-contact --company …` | `oo companies create --name …` |
|
||||
| `oo crm-add-contact --person-first …` | `oo persons create --first …` |
|
||||
| `oo crm-deals` | `oo opportunities list` |
|
||||
| `oo crm-deals --stages` | `oo opportunities stages` |
|
||||
| `oo crm-add-deal` | `oo opportunities create` |
|
||||
| `oo crm-cases` | `oo cases list` |
|
||||
| `oo applications-sync` | `oo applications sync` |
|
||||
|
||||
- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`,
|
||||
`oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks
|
||||
{list,create,delete,categories}`, `oo cases {create,delete,member-add}`,
|
||||
`oo contacts {get,info-add}`.
|
||||
|
||||
- **Global `--output/-o` flag**: all list-like commands now support
|
||||
`--output table` (default; tabwriter-aligned, truncated to 80 chars
|
||||
per cell) and `--output json`. Nested `bidCurrency` flattened to its
|
||||
`abbreviation` in the table view.
|
||||
|
||||
- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`,
|
||||
`oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`,
|
||||
`oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that
|
||||
removes things.
|
||||
|
||||
### Added
|
||||
|
||||
- `cmd/oo/common.go` — shared `printTable(headers, rows)` and
|
||||
`printObject(v)` helpers that dispatch on the `--output` flag.
|
||||
- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami`
|
||||
via the library's `GetUsers` + `SelfUserID`.
|
||||
- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`,
|
||||
`UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`.
|
||||
- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from
|
||||
project `oo tasks`).
|
||||
- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared
|
||||
list/filter implementation.
|
||||
|
||||
### Changed — library (minor)
|
||||
|
||||
- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())`
|
||||
so CLI aborts propagate to the auth request.
|
||||
|
||||
## [0.4.0] - 2026-04-24
|
||||
|
||||
### Changed — project structure
|
||||
|
||||
- **Library files reorganised by domain** (mechanical split; zero API surface
|
||||
change). The former monolithic `onlyoffice.go` (687 LOC) is now split into:
|
||||
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
|
||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`,
|
||||
`Permissions`, `requestBodyReader`.
|
||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`,
|
||||
`Auth`, `ensureToken`, `authHeader`, `tokenValid`.
|
||||
- `http.go` — transport helpers and DRY response decoders
|
||||
(`ResponseArray`, `ResponseObject`, `postFormObject`, `putFormObject`,
|
||||
`deleteObject`, `unmarshalResponseObject`).
|
||||
- `projects.go` — `Project`, `Projects`, `Milestone`, `ProjectOwner` +
|
||||
`GetProjects` / `CreateProject` / `UpdateProject` / `DeleteProject` /
|
||||
`GetProjectByID` / `GetProjectMilestones`.
|
||||
- `tasks.go` — `Task`, `ProjectTaskStatus`, `TaskPriority`,
|
||||
`ProjectGetTasksRequest` et al. **plus** the form-encoded helpers
|
||||
formerly in `tasks_extra.go` (`ListTasks`, `AddTask`, `AddSubtask`,
|
||||
`UpdateTaskStatus`, `DeleteTask`, `GetTaskByID`).
|
||||
- `users.go` — `User`, `Contact`, `Group`, `GetUsers`, `SelfUserID`.
|
||||
- `calendar.go`, `crm.go`, `files.go` — unchanged in scope, refactored
|
||||
through the new DRY helpers.
|
||||
- `httpx.go` → **renamed** `http.go`.
|
||||
- `onlyoffice.go` and `tasks_extra.go` — **deleted** (content redistributed).
|
||||
|
||||
### Changed — CLI **BREAKING**
|
||||
|
||||
- **Binary renamed `oo-cli` → `oo`.** Install with
|
||||
`go install github.com/eslider/go-onlyoffice/cmd/oo@latest`.
|
||||
- **Package path `cmd/oo-cli` → `cmd/oo`.** The old path is removed.
|
||||
- **`internal/cli` is gone.** Cobra commands now live directly under
|
||||
`cmd/oo/` as `package main`, split by domain: `calendar.go`, `crm.go`,
|
||||
`tasks.go`, `apps.go`, `common.go`. Rationale: cobra wiring is a CLI-only
|
||||
concern and does not belong inside a `pkg-level internal/`.
|
||||
- **`internal/applications` → `cmd/oo/applications/`.** This is a
|
||||
CV-specific CRM workflow — not a general OnlyOffice feature — and is only
|
||||
consumed by the `oo` CLI. Keeping it under `cmd/oo/` prevents accidental
|
||||
external adoption and makes the coupling explicit.
|
||||
- **`examples/applications/` removed.** It imported an internal package,
|
||||
which was a policy smell. Remaining examples (`basic`, `calendar`, `crm`,
|
||||
`subtasks`) use only the exported library surface.
|
||||
|
||||
### Added
|
||||
|
||||
- `(*Client).ResponseObject` — GET-and-decode-object counterpart to the
|
||||
existing `ResponseArray`.
|
||||
- `(*Client).postFormObject` / `putFormObject` / `deleteObject` — eliminate
|
||||
the ~15 identical "form request → `responseField` → `json.Unmarshal`"
|
||||
blocks previously duplicated across `crm.go` / `tasks_extra.go` /
|
||||
`calendar.go` / `files.go`.
|
||||
|
||||
### Migration
|
||||
|
||||
External library consumers: **no changes required**. The module path
|
||||
(`github.com/eslider/go-onlyoffice`), the `onlyoffice` package name, and
|
||||
every exported symbol are unchanged.
|
||||
|
||||
CLI users: replace `oo-cli` with `oo` in scripts and CI. The command set
|
||||
and flags are identical.
|
||||
|
||||
## [0.3.2] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- `Project.String()` no longer interprets the title as a format string
|
||||
(`fmt.Sprintf(*p.Title)`) and is now nil-safe on a zero-value `Project`.
|
||||
- `internal/applications.buildSummary` no longer panics at regex compile time
|
||||
on Go 1.23+ — the previous `(?= ...)` lookahead is replaced with an RE2-safe
|
||||
non-capturing trailing delimiter.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Client.Query` now routes token acquisition through the shared
|
||||
`ensureToken` path instead of duplicating the auth-expiry check inline.
|
||||
- Request body marshalling is consolidated into an unexported
|
||||
`requestBodyReader` helper (DRY; no change to the public surface).
|
||||
- The `Request.Debug` field is preserved for backwards compatibility but no
|
||||
longer changes behaviour — both branches used to unmarshal into the same
|
||||
target value. We'll remove the field in a future major release.
|
||||
|
||||
### Tests
|
||||
|
||||
- Deleted `httptest.NewServer` fixtures that emulated OnlyOffice protocol
|
||||
endpoints. Replaced them with:
|
||||
- pure-Go unit tests in `unit_test.go` (no network);
|
||||
- real integration tests in `client_test.go` guarded by
|
||||
`//go:build integration`. Run with
|
||||
`go test -tags=integration ./...`. Tests skip cleanly when
|
||||
`ONLYOFFICE_URL/USER/PASS` (or aliases) are absent.
|
||||
- New policy documented in `AGENTS.md` and
|
||||
`.cursor/rules/no-synthetic-mocks.mdc`.
|
||||
|
||||
## [0.3.1] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and
|
||||
deadlines. Preferred entry point for long-running syncs (cron, watchers).
|
||||
- `InvalidateToken()` — clears the cached token to force re-auth on the next
|
||||
request. Use this to recover from a mid-sync 401 when the server has revoked
|
||||
the session while the local `Expires` timestamp still looks fresh.
|
||||
|
||||
### Notes
|
||||
|
||||
- Plain `Authenticate()` is unchanged and remains a convenience wrapper around
|
||||
`AuthenticateContext(context.Background())`.
|
||||
- No breaking changes; a patch release.
|
||||
|
||||
## [0.3.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- Calendar helpers: `ListCalendars`, `ListEvents`, `AddEvent`, `DeleteEvent`.
|
||||
- CRM helpers: contacts (`ListContacts`, `GetContact`, `FindCompany`, `FindPerson`,
|
||||
`CreateCompany`, `CreatePerson`, `AddContactInfo`, `DeleteContact`), deals
|
||||
(`ListOpportunities`, `GetOpportunity`, `CreateOpportunity`,
|
||||
`AddOpportunityMember`, `ListDealStages`, `DeleteOpportunity`), cases
|
||||
(`ListCases`, `CreateCase`, `AddCaseMember`, `DeleteCase`), CRM tasks
|
||||
(`ListCRMTasks`, `CreateCRMTask`, `DeleteCRMTask`, `ListTaskCategories`), and
|
||||
history notes (`AddHistoryNote`).
|
||||
- Project task extras: `GetProjectByID`, `ListTasks`, `ListAllTasks`,
|
||||
`GetTaskByID`, `AddTask`, `AddSubtask`, `UpdateTaskStatus`, `DeleteTask`.
|
||||
- File upload: `UploadOpportunityFile` (multipart).
|
||||
- `SelfUserID` cached lookup of `people/@self`.
|
||||
- `Defaults` struct + `SetDefaults` + `GetEnvironmentDefaults` for optional
|
||||
calendar/project fallbacks.
|
||||
- Alias env vars accepted by `GetEnvironmentCredentials`:
|
||||
`ONLYOFFICE_HOST` / `ONLYOFFICE_NAME` / `ONLYOFFICE_PASSWORD`.
|
||||
- Public `Authenticate()` that primes the token eagerly.
|
||||
- Bundled CLI: `cmd/oo-cli` (Cobra) with commands `cal-list`, `cal-events`,
|
||||
`cal-add`, `cal-delete`, `task-list`, `task-add`, `subtask-add`,
|
||||
`task-update`, `crm-contacts`, `crm-add-contact`, `crm-deals`,
|
||||
`crm-add-deal`, `crm-cases`, `applications-sync`.
|
||||
- httptest-based unit tests for form / multipart / CRM helpers.
|
||||
|
||||
### Changed
|
||||
|
||||
- The `onlyoffice.Client` struct gained unexported fields (`defaults`, `selfID`,
|
||||
`noteCatID`); the public API is unchanged and remains backwards compatible.
|
||||
|
||||
## [0.2.0] - earlier
|
||||
|
||||
- Badges, docs expansion (Gitea sync use case, Gantt/PM workflows).
|
||||
|
||||
## [0.1.0] - earlier
|
||||
|
||||
- Initial release: OnlyOffice Project Management API client.
|
||||
@@ -1,5 +1,12 @@
|
||||
# go-onlyoffice
|
||||
|
||||
[](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev)
|
||||
[](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml)
|
||||
[](https://github.com/eSlider/go-onlyoffice/releases)
|
||||
[](https://github.com/eSlider/go-onlyoffice/stargazers)
|
||||
|
||||
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically.
|
||||
|
||||
Pairs with [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) to bridge developer issue trackers with CRM-grade project management for Gantt charts, resource planning, and executive reporting.
|
||||
@@ -504,19 +511,382 @@ type Task struct {
|
||||
| `Time` | `time.Time` wrapper with OnlyOffice JSON format |
|
||||
| `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description |
|
||||
|
||||
### Calendar, CRM, Subtasks, File upload (v0.2+)
|
||||
|
||||
Since v0.2 the library also exposes OnlyOffice Workspace surfaces beyond
|
||||
Projects: Calendar events, CRM (Contacts, Companies, Opportunities, Cases,
|
||||
Tasks, History notes) and opportunity file uploads. These helpers return
|
||||
untyped `map[string]any` for flexibility; callers that need typed structs
|
||||
should use the typed Project/Task API above.
|
||||
|
||||
```go
|
||||
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||
client.SetDefaults(onlyoffice.GetEnvironmentDefaults()) // optional
|
||||
ctx := context.Background()
|
||||
|
||||
// Calendar
|
||||
events, _ := client.ListEvents(ctx, "2025-01-01", "2025-12-31")
|
||||
client.AddEvent(ctx, "", "Interview", "2025-06-10T10:00:00Z", "2025-06-10T11:00:00Z", "", false)
|
||||
|
||||
// CRM
|
||||
deals, total, _ := client.ListOpportunities(ctx, 50, 0)
|
||||
company, _ := client.FindCompany(ctx, "ACME")
|
||||
|
||||
// Subtasks (form-encoded)
|
||||
client.AddSubtask(ctx, "4242", "Prepare CV")
|
||||
```
|
||||
|
||||
### oo (bundled CLI)
|
||||
|
||||
A ready-to-use [Cobra](https://github.com/spf13/cobra) CLI wrapping the
|
||||
library lives under [`cmd/oo`](cmd/oo/). The command tree is **subject-based**,
|
||||
mirroring the [`tea`](https://gitea.com/gitea/tea) CLI:
|
||||
|
||||
```bash
|
||||
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
|
||||
|
||||
# Global: every list-style command takes -o table|json (default: table)
|
||||
oo whoami
|
||||
oo users list
|
||||
oo calendar events --start 2026-04-24 --end 2026-05-01
|
||||
oo projects list
|
||||
oo projects get 33
|
||||
oo tasks list --all --verbose
|
||||
oo tasks subtask add 4242 "Prepare 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
|
||||
```
|
||||
|
||||
### office (TUI)
|
||||
|
||||
Terminal UI for browsing OnlyOffice Workspace — module tree (left), selectable
|
||||
lists (center), and markdown preview (right). Uses the same `.env` credentials
|
||||
as `oo`. Lives under [`cmd/office`](cmd/office/).
|
||||
|
||||
```bash
|
||||
go install github.com/eslider/go-onlyoffice/cmd/office@latest
|
||||
office
|
||||
```
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Next pane (menu → list → preview) |
|
||||
| `Shift+Tab` | Previous pane |
|
||||
| `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
|
||||
| `Enter` / `→` | Expand tree branch or open leaf list |
|
||||
| `Space` | Toggle multi-select on list row |
|
||||
| `Enter` / `a` | Action menu (view, delete, download, …) |
|
||||
| `r` | Refresh current list |
|
||||
| `q` | Quit |
|
||||
|
||||
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
|
||||
|
||||
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
|
||||
|
||||
```bash
|
||||
ONLYOFFICE_DOCS_URL=https://docs.example.com
|
||||
ONLYOFFICE_DOCS_SECRET=… # when JWT signing is enabled
|
||||
```
|
||||
|
||||
Spreadsheet files: inline CSV/JSON preview in the right pane; install
|
||||
[`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen
|
||||
xlsx/csv viewing (`v` on a file row — coming in next iteration).
|
||||
|
||||
Integration tests for list loaders and preview (live API only — no mocks):
|
||||
|
||||
```bash
|
||||
go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
|
||||
```
|
||||
|
||||
**Project / task documents (`oo`):**
|
||||
|
||||
```bash
|
||||
# Project Documents (files module)
|
||||
oo projects files list 33
|
||||
oo projects files upload 33 ./notes.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
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `ONLYOFFICE_URL` | OnlyOffice instance URL |
|
||||
| `ONLYOFFICE_USER` | Login email or username |
|
||||
| `ONLYOFFICE_PASS` | Password |
|
||||
| `ONLYOFFICE_URL` (or `ONLYOFFICE_HOST`) | OnlyOffice instance URL |
|
||||
| `ONLYOFFICE_USER` (or `ONLYOFFICE_NAME`) | Login email or username |
|
||||
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
||||
| `OO_URL`, `OO_USER`, `OO_PASS` | CLI-only produktor.io aliases mapped to `ONLYOFFICE_URL`, `ONLYOFFICE_USER`, `ONLYOFFICE_PASS` |
|
||||
|
||||
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
|
||||
|
||||
| Example | Description |
|
||||
|---|---|
|
||||
| [basic](examples/basic/) | List projects and users |
|
||||
| [calendar](examples/calendar/) | List calendars and events, create a new event |
|
||||
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
|
||||
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
|
||||
| [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules |
|
||||
| [`cmd/office`](cmd/office/) | Terminal UI — browse Workspace with markdown preview |
|
||||
|
||||
## Related Libraries
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package onlyoffice
|
||||
|
||||
// Authentication primitives: token lifecycle, eager / context-aware auth, and
|
||||
// token invalidation. Split out of http.go so auth concerns live together.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Auth authenticates using the given credentials and returns a fresh token.
|
||||
// Most callers should not call this directly — use Authenticate /
|
||||
// AuthenticateContext, or let Query fetch a token lazily. Kept exported for
|
||||
// backwards compatibility.
|
||||
func (c *Client) Auth(creds *Credentials) (*Token, error) {
|
||||
t := &Token{}
|
||||
return t, c.Query(Request{
|
||||
Uri: "/api/2.0/authentication.json",
|
||||
Method: http.MethodPost,
|
||||
Body: creds,
|
||||
NoAuth: true,
|
||||
}, &struct {
|
||||
MetaResponse `json:",inline"`
|
||||
Response *Token `json:"response"`
|
||||
}{
|
||||
Response: t,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticate validates credentials and primes the token. Library users may
|
||||
// call this eagerly to surface auth errors at startup; otherwise the token
|
||||
// is fetched lazily on the first request.
|
||||
//
|
||||
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
|
||||
func (c *Client) Authenticate() error { return c.ensureToken() }
|
||||
|
||||
// AuthenticateContext is the context-aware variant of Authenticate. If the
|
||||
// cached token is still valid it returns immediately; otherwise it performs
|
||||
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
|
||||
//
|
||||
// This is the recommended entry point for long-running syncs (cron,
|
||||
// watchers) because it guarantees that a stalled auth call will not block
|
||||
// the caller past its deadline.
|
||||
func (c *Client) AuthenticateContext(ctx context.Context) error {
|
||||
if c.tokenValid() {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(c.credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal credentials: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
var env struct {
|
||||
Response *Token `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return fmt.Errorf("auth decode: %w", err)
|
||||
}
|
||||
if env.Response == nil || env.Response.Value == "" {
|
||||
return fmt.Errorf("auth: empty token in response")
|
||||
}
|
||||
c.token = env.Response
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateToken clears the cached authentication token. The next request
|
||||
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
|
||||
//
|
||||
// Use this to recover from a mid-sync 401 when the server has revoked or
|
||||
// rotated the session while the Expires timestamp still looks fresh locally.
|
||||
func (c *Client) InvalidateToken() { c.token = nil }
|
||||
|
||||
// tokenValid reports whether the cached token is present and not expired.
|
||||
func (c *Client) tokenValid() bool {
|
||||
return c.token != nil && !time.Time(c.token.Expires).Before(time.Now())
|
||||
}
|
||||
|
||||
// ensureToken refreshes the authentication token when missing or expired.
|
||||
// Mirrors the logic inline in Query() but is safe to call from helpers that
|
||||
// bypass the typed Request abstraction.
|
||||
func (c *Client) ensureToken() error {
|
||||
if c.tokenValid() {
|
||||
return nil
|
||||
}
|
||||
tok, err := c.Auth(c.credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.token = tok
|
||||
return nil
|
||||
}
|
||||
|
||||
// authHeader returns the value for the Authorization header, ensuring a token.
|
||||
func (c *Client) authHeader() (string, error) {
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.token.Value, nil
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
|
||||
// Empty start/end fall back to a wide default window (2020-01-01..2030-12-31),
|
||||
// matching the Python reference implementation.
|
||||
func (c *Client) ListCalendars(ctx context.Context, start, end string) ([]map[string]any, error) {
|
||||
if start == "" {
|
||||
start = "2020-01-01"
|
||||
}
|
||||
if end == "" {
|
||||
end = "2030-12-31"
|
||||
}
|
||||
path := fmt.Sprintf("/api/2.0/calendar/calendars/%s/%s.json", url.PathEscape(start), url.PathEscape(end))
|
||||
return c.ResponseArray(ctx, path)
|
||||
}
|
||||
|
||||
// ListEvents is an alias for ListCalendars; retained for the Python-port CLI.
|
||||
func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[string]any, error) {
|
||||
return c.ListCalendars(ctx, start, end)
|
||||
}
|
||||
|
||||
// AddEvent creates a simple one-shot calendar event. When calendarID is empty
|
||||
// the configured default (SetDefaults or GetEnvironmentDefaults) is used.
|
||||
// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601).
|
||||
//
|
||||
// The underlying API returns an array wrapper — this helper unwraps the first
|
||||
// element for convenience.
|
||||
func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) {
|
||||
if calendarID == "" {
|
||||
calendarID = c.defaults.CalendarID
|
||||
}
|
||||
if calendarID == "" {
|
||||
return nil, fmt.Errorf("AddEvent: calendarID is required (pass explicitly or set via SetDefaults)")
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("name", title)
|
||||
fields.Set("description", description)
|
||||
fields.Set("startDate", start)
|
||||
fields.Set("endDate", end)
|
||||
fields.Set("repeatType", "")
|
||||
fields.Set("alertType", "0")
|
||||
fields.Set("isAllDayLong", strconv.FormatBool(allDay))
|
||||
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := responseField(raw, "response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var arr []map[string]any
|
||||
if err := json.Unmarshal(resp, &arr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil, fmt.Errorf("AddEvent: empty response")
|
||||
}
|
||||
return arr[0], nil
|
||||
}
|
||||
|
||||
// DeleteEvent removes a single calendar event by its event ID.
|
||||
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
+173
-81
@@ -1,128 +1,220 @@
|
||||
//go:build integration
|
||||
|
||||
package onlyoffice
|
||||
|
||||
// Integration tests — hit a live OnlyOffice Workspace instance. Credentials
|
||||
// come from ONLYOFFICE_URL / ONLYOFFICE_USER / ONLYOFFICE_PASS (aliases
|
||||
// _HOST / _NAME / _PASSWORD also accepted). Without credentials every test
|
||||
// here skips.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// go test -tags=integration ./...
|
||||
//
|
||||
// These tests are destructive on the target instance. They create projects
|
||||
// with titles prefixed "go-onlyoffice-test-" and clean up afterwards. Do not
|
||||
// run against an instance you don't own.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Load environment variables
|
||||
func init() {
|
||||
// Load from .env file in the project root
|
||||
_ = godotenv.Load(".env")
|
||||
}
|
||||
|
||||
func skipWithoutCredentials(t *testing.T) {
|
||||
func skipWithoutCredentials(t *testing.T) Credentials {
|
||||
t.Helper()
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" {
|
||||
t.Skip("ONLYOFFICE_URL is not set, skipping integration test")
|
||||
c := GetEnvironmentCredentials()
|
||||
if c.Url == "" || c.User == "" || c.Password == "" {
|
||||
t.Skip("ONLYOFFICE_URL/USER/PASS not set — skipping integration test")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
skipWithoutCredentials(t)
|
||||
func liveClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
c := NewClient(skipWithoutCredentials(t))
|
||||
c.SetDefaults(GetEnvironmentDefaults())
|
||||
return c
|
||||
}
|
||||
|
||||
// Create a new OnlyOffice client
|
||||
credentials := GetEnvironmentCredentials()
|
||||
client := NewClient(credentials)
|
||||
var token *Token
|
||||
var err error
|
||||
token, err = client.Auth(&credentials)
|
||||
const testProjectPrefix = "go-onlyoffice-test-"
|
||||
|
||||
func cleanupTestProjects(t *testing.T, c *Client) {
|
||||
t.Helper()
|
||||
projects, err := c.GetProjects()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get token: %v", err)
|
||||
t.Logf("cleanup: GetProjects: %v", err)
|
||||
return
|
||||
}
|
||||
for _, p := range projects {
|
||||
if p.Title != nil && strings.HasPrefix(*p.Title, testProjectPrefix) {
|
||||
if _, err := c.DeleteProject(*p.ID); err != nil {
|
||||
t.Logf("cleanup: DeleteProject %d: %v", *p.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if token == nil {
|
||||
t.Error("Value is empty")
|
||||
}
|
||||
|
||||
// Clean up: remove all projects start with "Test project"
|
||||
projects, err := client.GetProjects()
|
||||
func TestIntegrationAuthenticateContext(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
t.Fatalf("AuthenticateContext: %v", err)
|
||||
}
|
||||
if c.token == nil || c.token.Value == "" {
|
||||
t.Fatal("token not cached after AuthenticateContext")
|
||||
}
|
||||
// Second call must hit the cache.
|
||||
cachedValue := c.token.Value
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
t.Fatalf("AuthenticateContext (cached): %v", err)
|
||||
}
|
||||
if c.token.Value != cachedValue {
|
||||
t.Fatal("cached token was replaced unexpectedly")
|
||||
}
|
||||
// Invalidate forces re-auth.
|
||||
c.InvalidateToken()
|
||||
if c.token != nil {
|
||||
t.Fatal("InvalidateToken did not clear the cache")
|
||||
}
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
t.Fatalf("AuthenticateContext (after invalidate): %v", err)
|
||||
}
|
||||
if c.token == nil {
|
||||
t.Fatal("post-invalidate auth left token nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationGetProjectsAndSelf(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
projects, err := c.GetProjects()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get projects: %v", err)
|
||||
t.Fatalf("GetProjects: %v", err)
|
||||
}
|
||||
|
||||
for _, project := range projects {
|
||||
if strings.HasPrefix(*project.Title, "Test project") {
|
||||
prjStatus, err := client.DeleteProject(*project.ID)
|
||||
if projects == nil {
|
||||
t.Fatal("nil slice from GetProjects")
|
||||
}
|
||||
ctx := context.Background()
|
||||
uid, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to delete project: %v", err)
|
||||
}
|
||||
if *prjStatus.ID != *project.ID {
|
||||
t.Errorf("Project ID is not equal: %v != %v", prjStatus.ID, project.ID)
|
||||
t.Fatalf("SelfUserID: %v", err)
|
||||
}
|
||||
if uid == "" {
|
||||
t.Fatal("empty self user id")
|
||||
}
|
||||
}
|
||||
|
||||
// Test create project
|
||||
project, err := client.CreateProject(NewProjectRequest{
|
||||
Title: "Test project",
|
||||
Description: "Test project description",
|
||||
func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
t.Cleanup(func() { cleanupTestProjects(t, c) })
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
title := testProjectPrefix + suffix
|
||||
project, err := c.CreateProject(NewProjectRequest{
|
||||
Title: title,
|
||||
Description: "integration test from go-onlyoffice",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create project: %v", err)
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
if project.ID == nil {
|
||||
t.Fatal("created project without id")
|
||||
}
|
||||
|
||||
// Update project
|
||||
project, err = client.UpdateProject(ProjectUpdateRequest{
|
||||
updated, err := c.UpdateProject(ProjectUpdateRequest{
|
||||
ID: *project.ID,
|
||||
Title: "Test project updated",
|
||||
Title: title + " (updated)",
|
||||
Description: "updated",
|
||||
ResponsibleID: *project.Responsible.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProject: %v", err)
|
||||
}
|
||||
if updated.Title == nil || *updated.Title != title+" (updated)" {
|
||||
t.Errorf("title not updated: %+v", updated.Title)
|
||||
}
|
||||
|
||||
var task *Task
|
||||
// Test create project task
|
||||
task, err = client.CreateProjectTask(NewProjectTaskRequest{
|
||||
start := Time(time.Now().AddDate(0, 0, -2))
|
||||
deadline := Time(time.Now().AddDate(0, 0, 2))
|
||||
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||
ProjectId: *project.ID,
|
||||
Title: "Test task",
|
||||
Description: "Test task description",
|
||||
Notify: true,
|
||||
MilestoneId: 0,
|
||||
Priority: 0,
|
||||
// Deadline +2 days
|
||||
StartDate: Time(time.Now().AddDate(0, 0, -2)),
|
||||
Deadline: Time(time.Now().AddDate(0, 0, 2)),
|
||||
Title: "integration parent task",
|
||||
Description: "from go-onlyoffice integration suite",
|
||||
StartDate: start,
|
||||
Deadline: deadline,
|
||||
Priority: int(TaskPriorityNormal),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProjectTask: %v", err)
|
||||
}
|
||||
if task.ID == nil {
|
||||
t.Fatal("created task without id")
|
||||
}
|
||||
|
||||
// Update project task
|
||||
startDate := Time(time.Now().AddDate(0, 0, -14))
|
||||
deadline := Time(time.Now().AddDate(0, 0, 3))
|
||||
task, err = client.UpdateProjectTask(ProjectTaskUpdateRequest{
|
||||
newStart := Time(time.Now().AddDate(0, 0, -14))
|
||||
newDeadline := Time(time.Now().AddDate(0, 0, 3))
|
||||
if _, err := c.UpdateProjectTask(ProjectTaskUpdateRequest{
|
||||
ID: *task.ID,
|
||||
Title: "Test task updated",
|
||||
Description: "Test task description updated",
|
||||
StartDate: &startDate,
|
||||
Deadline: &deadline,
|
||||
})
|
||||
Title: "integration parent task (updated)",
|
||||
Description: "updated",
|
||||
StartDate: &newStart,
|
||||
Deadline: &newDeadline,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateProjectTask: %v", err)
|
||||
}
|
||||
|
||||
// Subtask uses the form-encoded helper; exercises httpx.postForm path.
|
||||
ctx := context.Background()
|
||||
sub, err := c.AddSubtask(ctx, strconv.Itoa(*task.ID), "integration subtask")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create task: %v", err)
|
||||
t.Fatalf("AddSubtask: %v", err)
|
||||
}
|
||||
if _, ok := sub["id"]; !ok {
|
||||
t.Errorf("subtask response missing id: %+v", sub)
|
||||
}
|
||||
}
|
||||
|
||||
if task == nil {
|
||||
t.Error("Value is empty")
|
||||
func TestIntegrationCalendarAndCRMRead(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx := context.Background()
|
||||
start := time.Now().Format("2006-01-02")
|
||||
end := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
|
||||
|
||||
if _, err := c.ListCalendars(ctx, start, end); err != nil {
|
||||
t.Errorf("ListCalendars: %v", err)
|
||||
}
|
||||
if _, err := c.ListEvents(ctx, start, end); err != nil {
|
||||
t.Errorf("ListEvents: %v", err)
|
||||
}
|
||||
if _, _, err := c.ListContacts(ctx, 5, 0, ""); err != nil {
|
||||
t.Errorf("ListContacts: %v", err)
|
||||
}
|
||||
if _, _, err := c.ListOpportunities(ctx, 5, 0); err != nil {
|
||||
t.Errorf("ListOpportunities: %v", err)
|
||||
}
|
||||
if _, err := c.ListDealStages(ctx); err != nil {
|
||||
t.Errorf("ListDealStages: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete project
|
||||
prj, err := client.DeleteProject(*project.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to delete project: %v", err)
|
||||
// TestIntegrationDryRunList covers the tiniest project-tasks read that
|
||||
// inventar-sync depends on; confirms GetTasks deserialises real responses.
|
||||
func TestIntegrationListTasks(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
defaults := GetEnvironmentDefaults()
|
||||
// ProjectID default is "33"; skip the read-only list if the caller hasn't
|
||||
// pointed us at a valid project (we don't know which projects exist).
|
||||
if defaults.ProjectID == "" {
|
||||
t.Skip("ONLYOFFICE_PROJECT_ID not configured")
|
||||
}
|
||||
|
||||
if *prj.ID != *project.ID {
|
||||
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID)
|
||||
pid, err := strconv.Atoi(defaults.ProjectID)
|
||||
if err != nil || pid <= 0 {
|
||||
t.Skipf("ONLYOFFICE_PROJECT_ID %q is not a positive int", defaults.ProjectID)
|
||||
}
|
||||
|
||||
// Test list projects
|
||||
projects, err = client.GetProjects()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get projects: %v", err)
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
t.Error("Value is empty")
|
||||
if _, err := c.GetTasks(NewProjectGetTasksRequest(pid)); err != nil {
|
||||
t.Fatalf("GetTasks(%d): %v", pid, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
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
|
||||
}
|
||||
raw := map[string]any{
|
||||
"id": id,
|
||||
"title": title,
|
||||
}
|
||||
if p.TaskCount != nil {
|
||||
raw["taskCount"] = *p.TaskCount
|
||||
}
|
||||
if p.TaskCountTotal != nil {
|
||||
raw["taskCountTotal"] = *p.TaskCountTotal
|
||||
}
|
||||
if p.DocumentsCount != nil {
|
||||
raw["documentsCount"] = *p.DocumentsCount
|
||||
}
|
||||
if p.ParticipantCount != nil {
|
||||
raw["participantCount"] = *p.ParticipantCount
|
||||
}
|
||||
items[i] = model.Item{
|
||||
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
|
||||
rows, err := l.Client.ListAllTasks(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
|
||||
}
|
||||
|
||||
func (l *Loader) 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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
// PreviewMarkdown loads item data and returns markdown for the preview pane.
|
||||
func (l *Loader) PreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
|
||||
if l == nil || l.Client == nil {
|
||||
return "", fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
if item.Kind == model.KindFile {
|
||||
return l.filePreviewMarkdown(ctx, item)
|
||||
}
|
||||
raw, err := l.Detail(ctx, item)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return preview.EntityMarkdown(string(item.Kind), raw), nil
|
||||
}
|
||||
|
||||
func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
|
||||
if item.ID == "" {
|
||||
return "", fmt.Errorf("file id missing")
|
||||
}
|
||||
name := item.Title
|
||||
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
|
||||
if t := onlyoffice.FileEntryTitle(meta); t != "" {
|
||||
name = t
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return preview.FileBytesToMarkdown(name, buf.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// SaveItem persists editable form fields for supported entity kinds.
|
||||
func (l *Loader) SaveItem(ctx context.Context, item model.Item, title, description string) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
switch item.Kind {
|
||||
case model.KindTask:
|
||||
return l.UpdateTask(ctx, item.ID, title, description)
|
||||
case model.KindProject:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = l.Client.UpdateProject(onlyoffice.ProjectUpdateRequest{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Description: description,
|
||||
})
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("save not supported for %s", item.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// DetailForm loads form field values for the detail pane.
|
||||
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
|
||||
raw, err := l.Detail(ctx, item)
|
||||
if err != nil {
|
||||
return model.FormFields{}, err
|
||||
}
|
||||
return model.FormFieldsFromRaw(item.Kind, raw), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
// UpdateTask saves title and description for a project task.
|
||||
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
|
||||
if l == nil || l.Client == nil {
|
||||
return fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
id, err := strconv.Atoi(taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("task id %q: %w", taskID, err)
|
||||
}
|
||||
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Description: description,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// TaskFields loads title and description for a project task item.
|
||||
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
|
||||
fields, err := l.DetailForm(ctx, item)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return fields.Primary, fields.Secondary, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build integration
|
||||
|
||||
package fetch_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no tasks")
|
||||
}
|
||||
item := items[0]
|
||||
title, desc, err := loader.TaskFields(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreTitle, restoreDesc := title, desc
|
||||
t.Cleanup(func() {
|
||||
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
|
||||
})
|
||||
newTitle := title + " (office TUI test)"
|
||||
newDesc := desc + "\n\n_edited by office integration test_"
|
||||
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTitle != newTitle {
|
||||
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
|
||||
}
|
||||
if gotDesc != newDesc {
|
||||
t.Fatalf("description mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationTaskFieldsFromLiveAPI(t *testing.T) {
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||
t.Skip("ONLYOFFICE_URL not set")
|
||||
}
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no tasks")
|
||||
}
|
||||
title, desc, err := loader.TaskFields(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title == "" {
|
||||
t.Fatal("empty title")
|
||||
}
|
||||
_ = desc
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Command office is a terminal UI for OnlyOffice Workspace.
|
||||
//
|
||||
// Three-pane layout: module tree (left), selectable list (center),
|
||||
// markdown preview (right). Uses github.com/eslider/go-onlyoffice for API calls.
|
||||
//
|
||||
// Build & install:
|
||||
//
|
||||
// go install github.com/eslider/go-onlyoffice/cmd/office@latest
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := bootstrap.NewClient(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
m := ui.NewModel(client)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package model
|
||||
|
||||
// ActionID identifies an operation on a list item.
|
||||
type ActionID string
|
||||
|
||||
const (
|
||||
ActionView ActionID = "view"
|
||||
ActionSave ActionID = "save"
|
||||
ActionDelete ActionID = "delete"
|
||||
ActionRefresh ActionID = "refresh"
|
||||
ActionDownload ActionID = "download"
|
||||
)
|
||||
|
||||
// ItemAction is one CRUD-style operation offered for an item.
|
||||
type ItemAction struct {
|
||||
ID ActionID
|
||||
Label string
|
||||
Danger bool
|
||||
}
|
||||
|
||||
// ActionsFor returns available operations for an item kind (detail pane action bar).
|
||||
func ActionsFor(kind Kind) []ItemAction {
|
||||
switch kind {
|
||||
case KindProject:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||
}
|
||||
case KindTask:
|
||||
return []ItemAction{
|
||||
{ID: ActionSave, Label: "Save"},
|
||||
{ID: ActionDelete, Label: "Delete task", Danger: true},
|
||||
}
|
||||
case KindCRMTask:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||
}
|
||||
case KindContact:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete contact", Danger: true},
|
||||
}
|
||||
case KindOpportunity:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete deal", Danger: true},
|
||||
}
|
||||
case KindCase:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete case", Danger: true},
|
||||
}
|
||||
case KindMail:
|
||||
return []ItemAction{
|
||||
{ID: ActionDelete, Label: "Delete message", Danger: true},
|
||||
}
|
||||
case KindFile:
|
||||
return []ItemAction{
|
||||
{ID: ActionDownload, Label: "Download"},
|
||||
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
||||
}
|
||||
case KindEvent, KindCalendar, KindUser:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TableColumn is one column in the center data table.
|
||||
type TableColumn struct {
|
||||
Key string
|
||||
Title string
|
||||
Width int
|
||||
}
|
||||
|
||||
var subjectExtraKeys = map[Subject][]string{
|
||||
SubjectTasks: {"status", "deadline", "responsible"},
|
||||
SubjectCalendars: {"description"},
|
||||
SubjectEvents: {"start", "end"},
|
||||
SubjectContacts: {"primaryEmail", "displayName"},
|
||||
SubjectPersons: {"primaryEmail", "displayName"},
|
||||
SubjectCompanies: {"primaryEmail", "displayName"},
|
||||
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
|
||||
SubjectCases: {"status"},
|
||||
SubjectCRMTasks: {"status", "deadline"},
|
||||
SubjectMailInbox: {"from", "date"},
|
||||
SubjectMailSent: {"to", "date"},
|
||||
SubjectMailDrafts: {"to", "date"},
|
||||
SubjectMailTrash: {"from", "date"},
|
||||
SubjectMailSpam: {"from", "date"},
|
||||
SubjectUsers: {"email", "displayName"},
|
||||
}
|
||||
|
||||
// BuildColumns derives table columns from the list subject and item payloads.
|
||||
func BuildColumns(subject Subject, items []Item) []TableColumn {
|
||||
if subject == SubjectProjects {
|
||||
return buildProjectColumns(items)
|
||||
}
|
||||
cols := []TableColumn{
|
||||
{Key: "_sel", Title: "✓", Width: 3},
|
||||
{Key: "id", Title: "ID", Width: 10},
|
||||
{Key: "title", Title: "Title", Width: 28},
|
||||
}
|
||||
if hasSubtitle(items) {
|
||||
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
|
||||
}
|
||||
|
||||
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
|
||||
for _, key := range subjectExtraKeys[subject] {
|
||||
if seen[key] || !columnHasData(items, key) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||
}
|
||||
|
||||
discovered := discoverRawKeys(items, seen, 8)
|
||||
for _, key := range discovered {
|
||||
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
|
||||
}
|
||||
|
||||
sizeColumns(cols, items)
|
||||
return cols
|
||||
}
|
||||
|
||||
func buildProjectColumns(items []Item) []TableColumn {
|
||||
cols := []TableColumn{
|
||||
{Key: "_sel", Title: "✓", Width: 3},
|
||||
{Key: "id", Title: "ID", Width: 8},
|
||||
{Key: "title", Title: "Title", Width: 28},
|
||||
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
|
||||
{Key: "documents", Title: "Documents", Width: 11},
|
||||
{Key: "users", Title: "Users", Width: 8},
|
||||
}
|
||||
sizeColumns(cols, items)
|
||||
return cols
|
||||
}
|
||||
|
||||
// CellText returns the display string for one table cell.
|
||||
func CellText(it Item, key string) string {
|
||||
switch key {
|
||||
case "_sel":
|
||||
if it.Selected {
|
||||
return "●"
|
||||
}
|
||||
return "○"
|
||||
case "id":
|
||||
return it.ID
|
||||
case "title":
|
||||
return it.Title
|
||||
case "subtitle":
|
||||
return it.Subtitle
|
||||
case "tasks":
|
||||
return formatProjectTasks(it.Raw)
|
||||
case "documents":
|
||||
return intRaw(it.Raw, "documentsCount")
|
||||
case "users":
|
||||
return intRaw(it.Raw, "participantCount")
|
||||
case "kind":
|
||||
return string(it.Kind)
|
||||
default:
|
||||
if it.Raw == nil {
|
||||
return ""
|
||||
}
|
||||
return formatAny(it.Raw[key])
|
||||
}
|
||||
}
|
||||
|
||||
func formatProjectTasks(raw map[string]any) string {
|
||||
if raw == nil {
|
||||
return "0/0"
|
||||
}
|
||||
open := intRawVal(raw, "taskCount")
|
||||
total := intRawVal(raw, "taskCountTotal")
|
||||
closed := total - open
|
||||
if closed < 0 {
|
||||
closed = 0
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", open, closed)
|
||||
}
|
||||
|
||||
func intRaw(raw map[string]any, key string) string {
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
v := intRawVal(raw, key)
|
||||
if v == 0 {
|
||||
if _, ok := raw[key]; !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d", v)
|
||||
}
|
||||
|
||||
func intRawVal(raw map[string]any, key string) int {
|
||||
if raw == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := raw[key].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(v)
|
||||
return n
|
||||
default:
|
||||
if raw[key] == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
func hasSubtitle(items []Item) bool {
|
||||
for _, it := range items {
|
||||
if it.Subtitle != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func columnHasData(items []Item, key string) bool {
|
||||
for _, it := range items {
|
||||
if CellText(it, key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
if it.Raw == nil {
|
||||
continue
|
||||
}
|
||||
for k := range it.Raw {
|
||||
if seen[k] || k == "id" || k == "title" {
|
||||
continue
|
||||
}
|
||||
counts[k]++
|
||||
}
|
||||
}
|
||||
type kv struct {
|
||||
k string
|
||||
n int
|
||||
}
|
||||
var ranked []kv
|
||||
for k, n := range counts {
|
||||
ranked = append(ranked, kv{k, n})
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].n == ranked[j].n {
|
||||
return ranked[i].k < ranked[j].k
|
||||
}
|
||||
return ranked[i].n > ranked[j].n
|
||||
})
|
||||
out := make([]string, 0, limit)
|
||||
for _, r := range ranked {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
out = append(out, r.k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func titleLabel(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(key, "_")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func defaultWidth(key string) int {
|
||||
switch key {
|
||||
case "id", "status", "stage":
|
||||
return 10
|
||||
case "start", "end", "deadline", "date":
|
||||
return 18
|
||||
case "from", "to", "email", "primaryEmail":
|
||||
return 22
|
||||
case "description", "displayName":
|
||||
return 24
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
}
|
||||
|
||||
func sizeColumns(cols []TableColumn, items []Item) {
|
||||
for i := range cols {
|
||||
maxW := runeLen(cols[i].Title)
|
||||
for _, it := range items {
|
||||
n := runeLen(CellText(it, cols[i].Key))
|
||||
if n > maxW {
|
||||
maxW = n
|
||||
}
|
||||
}
|
||||
maxW += 2
|
||||
if maxW < 4 {
|
||||
maxW = 4
|
||||
}
|
||||
if maxW > 36 {
|
||||
maxW = 36
|
||||
}
|
||||
cols[i].Width = maxW
|
||||
}
|
||||
}
|
||||
|
||||
func runeLen(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
|
||||
func formatAny(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
if x {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return fmt.Sprintf("%d", int64(x))
|
||||
}
|
||||
return fmt.Sprintf("%g", x)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildProjectColumns(t *testing.T) {
|
||||
items := []Item{{
|
||||
ID: "1", Title: "Alpha", Kind: KindProject,
|
||||
Raw: map[string]any{
|
||||
"taskCount": 3, "taskCountTotal": 5,
|
||||
"documentsCount": 2, "participantCount": 4,
|
||||
},
|
||||
}}
|
||||
cols := BuildColumns(SubjectProjects, items)
|
||||
want := []string{"_sel", "id", "title", "tasks", "documents", "users"}
|
||||
if len(cols) != len(want) {
|
||||
t.Fatalf("got %d columns, want %d", len(cols), len(want))
|
||||
}
|
||||
for i, key := range want {
|
||||
if cols[i].Key != key {
|
||||
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
|
||||
}
|
||||
}
|
||||
for _, c := range cols {
|
||||
if c.Key == "status" || c.Key == "subtitle" {
|
||||
t.Fatalf("unexpected column %q", c.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatProjectTasksOpenClosed(t *testing.T) {
|
||||
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
|
||||
if got := formatProjectTasks(raw); got != "3/5" {
|
||||
t.Fatalf("got %q want 3/5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellTextProjectDocumentsUsers(t *testing.T) {
|
||||
it := Item{Raw: map[string]any{"documentsCount": 7, "participantCount": 2}}
|
||||
if got := CellText(it, "documents"); got != "7" {
|
||||
t.Fatalf("documents: %q", got)
|
||||
}
|
||||
if got := CellText(it, "users"); got != "2" {
|
||||
t.Fatalf("users: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildColumnsIncludesSelectionAndCoreFields(t *testing.T) {
|
||||
items := []Item{
|
||||
{ID: "1", Title: "Alpha", Kind: KindTask, Raw: map[string]any{"status": "Open"}},
|
||||
}
|
||||
cols := BuildColumns(SubjectTasks, items)
|
||||
if cols[0].Key != "_sel" {
|
||||
t.Fatalf("first column should be selection, got %q", cols[0].Key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
|
||||
// FormFields holds editable/display fields loaded from API detail.
|
||||
type FormFields struct {
|
||||
PrimaryLabel string
|
||||
SecondaryLabel string
|
||||
Primary string
|
||||
Secondary string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// KindHeading returns a short label for the detail pane header.
|
||||
func KindHeading(kind Kind, id string) string {
|
||||
return fmt.Sprintf("%s %s", kindLabel(kind), id)
|
||||
}
|
||||
|
||||
func kindLabel(kind Kind) string {
|
||||
switch kind {
|
||||
case KindProject:
|
||||
return "Project"
|
||||
case KindTask:
|
||||
return "Task"
|
||||
case KindCRMTask:
|
||||
return "CRM task"
|
||||
case KindContact:
|
||||
return "Contact"
|
||||
case KindOpportunity:
|
||||
return "Deal"
|
||||
case KindCase:
|
||||
return "Case"
|
||||
case KindMail:
|
||||
return "Mail"
|
||||
case KindEvent:
|
||||
return "Event"
|
||||
case KindCalendar:
|
||||
return "Calendar"
|
||||
case KindFile:
|
||||
return "Document"
|
||||
case KindUser:
|
||||
return "User"
|
||||
default:
|
||||
return string(kind)
|
||||
}
|
||||
}
|
||||
|
||||
// FormFieldsFromRaw maps API detail JSON to form fields for the right pane.
|
||||
func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
|
||||
switch kind {
|
||||
case KindMail:
|
||||
body := strRaw(raw, "body")
|
||||
if body == "" {
|
||||
body = strRaw(raw, "htmlBody")
|
||||
}
|
||||
return FormFields{
|
||||
PrimaryLabel: "Subject", SecondaryLabel: "Body",
|
||||
Primary: strRaw(raw, "subject"), Secondary: body,
|
||||
ReadOnly: true,
|
||||
}
|
||||
case KindUser:
|
||||
return FormFields{
|
||||
PrimaryLabel: "Name", SecondaryLabel: "Email",
|
||||
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"),
|
||||
ReadOnly: true,
|
||||
}
|
||||
case KindContact:
|
||||
name := strRaw(raw, "displayName")
|
||||
if name == "" {
|
||||
name = strRaw(raw, "title")
|
||||
}
|
||||
return FormFields{
|
||||
PrimaryLabel: "Name", SecondaryLabel: "About",
|
||||
Primary: name, Secondary: strRaw(raw, "about"),
|
||||
ReadOnly: true,
|
||||
}
|
||||
case KindEvent, KindCalendar:
|
||||
return FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
|
||||
ReadOnly: true,
|
||||
}
|
||||
default:
|
||||
title := strRaw(raw, "title")
|
||||
if title == "" {
|
||||
title = strRaw(raw, "name")
|
||||
}
|
||||
if title == "" {
|
||||
title = strRaw(raw, "subject")
|
||||
}
|
||||
return FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: title, Secondary: strRaw(raw, "description"),
|
||||
ReadOnly: kind != KindTask && kind != KindProject,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strRaw(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
if m[key] == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(m[key])
|
||||
}
|
||||
|
||||
// IsDocumentKind is true when the right pane should show file content, not a form.
|
||||
func IsDocumentKind(kind Kind) bool {
|
||||
return kind == KindFile
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormFieldsFromRawMail(t *testing.T) {
|
||||
raw := map[string]any{"subject": "Hi", "body": "Hello"}
|
||||
f := FormFieldsFromRaw(KindMail, raw)
|
||||
if f.Primary != "Hi" || f.Secondary != "Hello" || !f.ReadOnly {
|
||||
t.Fatalf("unexpected mail fields: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDocumentKind(t *testing.T) {
|
||||
if !IsDocumentKind(KindFile) {
|
||||
t.Fatal("file should be document")
|
||||
}
|
||||
if IsDocumentKind(KindTask) {
|
||||
t.Fatal("task should be form")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) != 1 || acts[0].ID != model.ActionDelete {
|
||||
t.Fatalf("expected delete only, got %v", acts)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,214 @@
|
||||
//go:build integration
|
||||
|
||||
package preview_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
func skipWithoutLiveAPI(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||
t.Skip("ONLYOFFICE_URL not set")
|
||||
}
|
||||
}
|
||||
|
||||
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
|
||||
t.Helper()
|
||||
skipWithoutLiveAPI(t)
|
||||
client, err := bootstrap.NewClient(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fetch.Loader{Client: client}, context.Background()
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no projects")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.EntityMarkdown(string(model.KindProject), raw)
|
||||
if md == "" {
|
||||
t.Fatal("empty markdown")
|
||||
}
|
||||
if !strings.Contains(md, items[0].Title) {
|
||||
t.Fatalf("markdown missing project title %q:\n%s", items[0].Title, md)
|
||||
}
|
||||
rendered, err := preview.RenderMarkdown(md, 80)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rendered == "" {
|
||||
t.Fatal("glamour produced empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewContactFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no contacts")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.ContactMarkdown(raw)
|
||||
if md == "" || !strings.Contains(md, "#") {
|
||||
t.Fatalf("unexpected contact markdown: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no opportunities")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.OpportunityMarkdown(raw)
|
||||
if !strings.Contains(md, items[0].Title) {
|
||||
t.Fatalf("markdown missing deal title:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewMailFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("inbox empty")
|
||||
}
|
||||
raw, err := loader.Detail(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := preview.MailMarkdown(raw)
|
||||
if md == "" || !strings.Contains(md, "From:") {
|
||||
t.Fatalf("unexpected mail markdown:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.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 TestIntegrationPreviewFileFromAPI(t *testing.T) {
|
||||
skipWithoutLiveAPI(t)
|
||||
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
||||
t.Skip("ONLYOFFICE_PROJECT_ID not set")
|
||||
}
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Skip("no project files")
|
||||
}
|
||||
md, err := loader.PreviewMarkdown(ctx, items[0])
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewMarkdown: %v", err)
|
||||
}
|
||||
if md == "" {
|
||||
t.Fatal("empty preview")
|
||||
}
|
||||
if !strings.Contains(md, items[0].Title) && !strings.Contains(md, "|") && !strings.Contains(md, "```") {
|
||||
t.Fatalf("unexpected file preview for %q:\n%s", items[0].Title, md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
|
||||
skipWithoutLiveAPI(t)
|
||||
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
||||
t.Skip("ONLYOFFICE_PROJECT_ID not set")
|
||||
}
|
||||
loader, ctx := liveLoader(t)
|
||||
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var csvItem *model.Item
|
||||
for i := range items {
|
||||
if strings.HasSuffix(strings.ToLower(items[i].Title), ".csv") {
|
||||
csvItem = &items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if csvItem == nil {
|
||||
t.Skip("no csv file in project documents")
|
||||
}
|
||||
var buf strings.Builder
|
||||
n, err := loader.Client.DownloadFile(ctx, csvItem.ID, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadFile: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("empty download")
|
||||
}
|
||||
md, err := preview.CSVToMarkdownTable([]byte(buf.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("CSVToMarkdownTable: %v", err)
|
||||
}
|
||||
if !strings.Contains(md, "|") {
|
||||
t.Fatalf("expected pipe table: %q", md)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
type listLoadedMsg struct {
|
||||
items []model.Item
|
||||
spec model.ListSpec
|
||||
err error
|
||||
}
|
||||
|
||||
type detailLoadedMsg struct {
|
||||
item model.Item
|
||||
document bool
|
||||
fields model.FormFields
|
||||
markdown string
|
||||
err error
|
||||
}
|
||||
|
||||
type detailSavedMsg struct {
|
||||
item model.Item
|
||||
title, description 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
|
||||
focus model.FocusPane
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
err string
|
||||
loading bool
|
||||
menuVP viewport.Model
|
||||
listTable DataTable
|
||||
detail DetailPane
|
||||
showMenu bool
|
||||
showList bool
|
||||
showDetail bool
|
||||
}
|
||||
|
||||
// NewModel constructs the TUI with an authenticated client.
|
||||
func NewModel(client *onlyoffice.Client) Model {
|
||||
h, w := 24, 80
|
||||
m := Model{
|
||||
client: client,
|
||||
loader: &fetch.Loader{Client: client},
|
||||
nav: model.DefaultNavTree(),
|
||||
selection: model.NewSelection(),
|
||||
focus: model.FocusMenu,
|
||||
status: "Tab: pane · row select loads detail · Ctrl+S save · q quit",
|
||||
height: h,
|
||||
width: w,
|
||||
}
|
||||
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
|
||||
m.listTable = newDataTable()
|
||||
m.detail = newDetailPane()
|
||||
m.showMenu, m.showList, m.showDetail = true, true, true
|
||||
m.menuVP.MouseWheelEnabled = true
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
|
||||
}
|
||||
|
||||
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.scrollFocusedPane(key) {
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusPreview {
|
||||
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
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 = NextVisibleFocus(m.focus, m.paneVis())
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionPrevPane:
|
||||
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
||||
m.syncPaneFocus()
|
||||
return m, nil
|
||||
case ActionMoveUp:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(-1)
|
||||
}
|
||||
m.moveUp()
|
||||
m.syncFocusedPane()
|
||||
return m, m.onListRowChanged()
|
||||
case ActionMoveDown:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(1)
|
||||
}
|
||||
m.moveDown()
|
||||
m.syncFocusedPane()
|
||||
return m, m.onListRowChanged()
|
||||
case ActionMoveLeft:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
m.listTable.MoveCol(-1)
|
||||
}
|
||||
return m, nil
|
||||
case ActionMoveRight:
|
||||
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
m.listTable.MoveCol(1)
|
||||
}
|
||||
return m, nil
|
||||
case ActionSort:
|
||||
if m.focus == model.FocusList && m.hasList {
|
||||
m.listTable.ToggleSort()
|
||||
}
|
||||
return m, nil
|
||||
case ActionToggleSelect:
|
||||
if m.hasList {
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx >= 0 {
|
||||
m.selection.Toggle(&m.items, idx)
|
||||
m.listTable.UpdateItems(m.items)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case ActionFocusDetail:
|
||||
if m.hasList && m.showDetail {
|
||||
m.focus = model.FocusPreview
|
||||
m.syncPaneFocus()
|
||||
m.detail.FocusContent()
|
||||
return m, m.onListRowChanged()
|
||||
}
|
||||
return m, nil
|
||||
case ActionToggleMenuPane:
|
||||
m.togglePane(1)
|
||||
return m, nil
|
||||
case ActionToggleListPane:
|
||||
m.togglePane(2)
|
||||
return m, nil
|
||||
case ActionToggleDetailPane:
|
||||
m.togglePane(3)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
case listLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
m.items = nil
|
||||
m.hasList = false
|
||||
m.listTable.Clear()
|
||||
m.detail.Clear()
|
||||
} else {
|
||||
m.err = ""
|
||||
m.items = msg.items
|
||||
m.listSpec = msg.spec
|
||||
m.hasList = true
|
||||
m.listTable.SetData(m.listSpec, m.items)
|
||||
}
|
||||
return m, m.onListRowChanged()
|
||||
|
||||
case detailLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
m.detail.Clear()
|
||||
} else {
|
||||
pw := m.paneLayout()
|
||||
w := pw.Detail - 4
|
||||
if w < 20 {
|
||||
w = 20
|
||||
}
|
||||
if msg.document {
|
||||
m.detail.LoadDocument(msg.item, msg.markdown, w)
|
||||
} else {
|
||||
m.detail.LoadForm(msg.item, msg.fields)
|
||||
}
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||
m.err = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case detailSavedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
} else {
|
||||
m.updateItemAfterSave(msg.item, msg.title, msg.description)
|
||||
fields, _ := m.loader.DetailForm(context.Background(), msg.item)
|
||||
fields.Primary = msg.title
|
||||
fields.Secondary = msg.description
|
||||
m.detail.LoadForm(msg.item, fields)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||
m.status = "Saved"
|
||||
m.err = ""
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case actionDoneMsg:
|
||||
m.loading = 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
|
||||
|
||||
case tea.MouseMsg:
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||
switch key {
|
||||
case "tab", "shift+tab", "backtab":
|
||||
m.detail.ToggleZone()
|
||||
return nil, true
|
||||
case "ctrl+s":
|
||||
m.loading = true
|
||||
return m.saveDetailCmd(), true
|
||||
case "enter":
|
||||
if m.detail.Zone() == detailZoneActions {
|
||||
act, ok := m.detail.SelectedAction()
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
m.loading = true
|
||||
if act.ID == model.ActionSave {
|
||||
return m.saveDetailCmd(), true
|
||||
}
|
||||
return m.executeActionCmd(act.ID, m.detail.Item()), true
|
||||
}
|
||||
}
|
||||
if m.detail.Zone() == detailZoneContent {
|
||||
return m.detail.Update(msg), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
|
||||
if m.detail.Zone() == detailZoneActions {
|
||||
m.detail.MoveAction(delta)
|
||||
return m, nil
|
||||
}
|
||||
if delta < 0 {
|
||||
m.detail.form.FocusPrev()
|
||||
} else {
|
||||
m.detail.form.FocusNext()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading…\n"
|
||||
}
|
||||
pw := m.paneLayout()
|
||||
h := m.paneHeight() + 2
|
||||
var parts []string
|
||||
if pw.Visibility.Menu {
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
|
||||
parts = append(parts, menuStyle.Render(m.menuVP.View()))
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
|
||||
parts = append(parts, listStyle.Render(m.listTable.View()))
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
|
||||
parts = append(parts, prevStyle.Render(m.detail.View()))
|
||||
}
|
||||
|
||||
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, parts...) + "\n" + bar
|
||||
}
|
||||
|
||||
func (m Model) paneVis() PaneVisibility {
|
||||
return PaneVisibility{Menu: m.showMenu, List: m.showList, Detail: m.showDetail}
|
||||
}
|
||||
|
||||
func (m Model) paneLayout() PaneWidths {
|
||||
return LayoutWidths(m.width, m.paneVis())
|
||||
}
|
||||
|
||||
func (m *Model) syncPaneFocus() {
|
||||
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
|
||||
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||
}
|
||||
|
||||
func (m *Model) togglePane(which int) {
|
||||
switch which {
|
||||
case 1:
|
||||
m.showMenu = !m.showMenu
|
||||
case 2:
|
||||
m.showList = !m.showList
|
||||
case 3:
|
||||
m.showDetail = !m.showDetail
|
||||
}
|
||||
if !m.showMenu && !m.showList && !m.showDetail {
|
||||
switch which {
|
||||
case 1:
|
||||
m.showMenu = true
|
||||
case 2:
|
||||
m.showList = true
|
||||
case 3:
|
||||
m.showDetail = true
|
||||
}
|
||||
}
|
||||
if !paneVisible(m.focus, m.paneVis()) {
|
||||
m.focus = firstVisibleFocus(m.paneVis())
|
||||
}
|
||||
m.layoutViewports()
|
||||
m.syncAllContent()
|
||||
m.syncPaneFocus()
|
||||
}
|
||||
|
||||
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
|
||||
m.listSpec = spec
|
||||
m.selection.Clear()
|
||||
m.hasList = false
|
||||
m.focus = model.FocusList
|
||||
m.listTable.SetFocused(true)
|
||||
m.detail.Clear()
|
||||
m.loading = true
|
||||
return m, m.loadListCmd(spec)
|
||||
}
|
||||
|
||||
func (m *Model) layoutViewports() {
|
||||
pw := m.paneLayout()
|
||||
h := m.paneHeight()
|
||||
if pw.Visibility.Menu {
|
||||
m.menuVP.Width = m.paneInnerWidth(pw.Menu)
|
||||
m.menuVP.Height = h
|
||||
}
|
||||
if pw.Visibility.List {
|
||||
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
|
||||
}
|
||||
if pw.Visibility.Detail {
|
||||
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) syncAllContent() {
|
||||
m.syncMenuContent()
|
||||
m.syncListTable()
|
||||
}
|
||||
|
||||
func (m *Model) syncListTable() {
|
||||
if !m.hasList {
|
||||
m.listTable.Clear()
|
||||
return
|
||||
}
|
||||
m.listTable.UpdateItems(m.items)
|
||||
}
|
||||
|
||||
func (m *Model) syncMenuContent() {
|
||||
var b strings.Builder
|
||||
b.WriteString("Navigation\n\n")
|
||||
menuW := m.menuVP.Width
|
||||
if menuW < 10 {
|
||||
menuW = 20
|
||||
}
|
||||
selectedStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("62")).
|
||||
Foreground(lipgloss.Color("255"))
|
||||
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 = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW))
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
m.menuVP.SetContent(b.String())
|
||||
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func (m *Model) syncFocusedPane() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.syncMenuContent()
|
||||
case model.FocusList:
|
||||
m.syncListTable()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) moveUp() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.nav.MoveUp()
|
||||
case model.FocusList:
|
||||
if m.hasList {
|
||||
m.listTable.MoveRow(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) moveDown() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.nav.MoveDown()
|
||||
case model.FocusList:
|
||||
if m.hasList {
|
||||
m.listTable.MoveRow(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncVPToLine(vp *viewport.Model, line int) {
|
||||
if line < 0 {
|
||||
line = 0
|
||||
}
|
||||
max := vp.TotalLineCount()
|
||||
if max == 0 {
|
||||
vp.YOffset = 0
|
||||
return
|
||||
}
|
||||
if line >= max {
|
||||
line = max - 1
|
||||
}
|
||||
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
|
||||
}
|
||||
maxOff := max - vp.Height
|
||||
if maxOff < 0 {
|
||||
maxOff = 0
|
||||
}
|
||||
if vp.YOffset > maxOff {
|
||||
vp.YOffset = maxOff
|
||||
}
|
||||
}
|
||||
|
||||
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) 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 (m *Model) onListRowChanged() tea.Cmd {
|
||||
if !m.hasList {
|
||||
m.detail.Clear()
|
||||
return nil
|
||||
}
|
||||
idx := m.listTable.ItemIndex()
|
||||
if idx < 0 || idx >= len(m.items) {
|
||||
m.detail.Clear()
|
||||
return nil
|
||||
}
|
||||
it := m.items[idx]
|
||||
if m.detail.LoadedID() == it.ID {
|
||||
return nil
|
||||
}
|
||||
m.loading = true
|
||||
return m.loadDetailCmd(it)
|
||||
}
|
||||
|
||||
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
if model.IsDocumentKind(item.Kind) {
|
||||
md, err := m.loader.PreviewMarkdown(ctx, item)
|
||||
return detailLoadedMsg{item: item, document: true, markdown: md, err: err}
|
||||
}
|
||||
fields, err := m.loader.DetailForm(ctx, item)
|
||||
return detailLoadedMsg{item: item, fields: fields, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) saveDetailCmd() tea.Cmd {
|
||||
item := m.detail.Item()
|
||||
title := m.detail.form.Primary()
|
||||
desc := m.detail.form.Secondary()
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
err := m.loader.SaveItem(ctx, item, title, desc)
|
||||
return detailSavedMsg{item: item, title: title, description: desc, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
|
||||
for i := range m.items {
|
||||
if m.items[i].ID != item.ID {
|
||||
continue
|
||||
}
|
||||
m.items[i].Title = title
|
||||
if m.items[i].Raw == nil {
|
||||
m.items[i].Raw = map[string]any{}
|
||||
}
|
||||
m.items[i].Raw["title"] = title
|
||||
m.items[i].Raw["description"] = description
|
||||
m.listTable.UpdateItems(m.items)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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 "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ui_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/ui"
|
||||
)
|
||||
|
||||
func TestKeyActionMenuDown(t *testing.T) {
|
||||
if got := ui.KeyAction("j", model.FocusMenu); got != ui.ActionMoveDown {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionShiftTabPrevPane(t *testing.T) {
|
||||
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
|
||||
if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
|
||||
t.Fatalf("tab on preview should stay in pane, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionSortAndColumns(t *testing.T) {
|
||||
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidths(t *testing.T) {
|
||||
menu, list, preview := ui.LayoutWidthsLegacy(120)
|
||||
if menu+list+preview != 120 {
|
||||
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
const detailContentRatio = 0.72
|
||||
|
||||
type detailMode int
|
||||
|
||||
const (
|
||||
detailEmpty detailMode = iota
|
||||
detailDocument
|
||||
detailForm
|
||||
)
|
||||
|
||||
type detailZone int
|
||||
|
||||
const (
|
||||
detailZoneContent detailZone = iota
|
||||
detailZoneActions
|
||||
)
|
||||
|
||||
// DetailPane is the right column: content top, CRUD actions bottom.
|
||||
type DetailPane struct {
|
||||
mode detailMode
|
||||
item model.Item
|
||||
loadedID string
|
||||
actions []model.ItemAction
|
||||
actionIdx int
|
||||
zone detailZone
|
||||
focused bool
|
||||
width int
|
||||
height int
|
||||
form EntityForm
|
||||
docVP viewport.Model
|
||||
docText string
|
||||
styles detailStyles
|
||||
}
|
||||
|
||||
type detailStyles struct {
|
||||
sep lipgloss.Style
|
||||
action lipgloss.Style
|
||||
actionOn lipgloss.Style
|
||||
actionDn lipgloss.Style
|
||||
empty lipgloss.Style
|
||||
}
|
||||
|
||||
func newDetailPane() DetailPane {
|
||||
d := DetailPane{
|
||||
form: newEntityForm(),
|
||||
docVP: viewport.New(40, 10),
|
||||
styles: newDetailStyles(),
|
||||
}
|
||||
d.docVP.MouseWheelEnabled = true
|
||||
return d
|
||||
}
|
||||
|
||||
func newDetailStyles() detailStyles {
|
||||
btn := lipgloss.NewStyle().Padding(0, 1).MarginRight(1)
|
||||
return detailStyles{
|
||||
sep: lipgloss.NewStyle().Foreground(lipgloss.Color("238")),
|
||||
action: btn.Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")),
|
||||
actionOn: btn.Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||
actionDn: btn.Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
|
||||
empty: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DetailPane) Clear() {
|
||||
d.mode = detailEmpty
|
||||
d.item = model.Item{}
|
||||
d.loadedID = ""
|
||||
d.actions = nil
|
||||
d.actionIdx = 0
|
||||
d.docText = ""
|
||||
d.form.Clear()
|
||||
d.docVP.SetContent("")
|
||||
}
|
||||
|
||||
func (d *DetailPane) SetFocused(on bool) {
|
||||
d.focused = on
|
||||
d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm)
|
||||
}
|
||||
|
||||
func (d *DetailPane) SetSize(w, h int) {
|
||||
if w < 8 {
|
||||
w = 8
|
||||
}
|
||||
if h < 8 {
|
||||
h = 8
|
||||
}
|
||||
d.width = w
|
||||
d.height = h
|
||||
contentH, _ := d.splitHeights()
|
||||
d.form.SetSize(w, contentH)
|
||||
d.docVP.Width = w
|
||||
d.docVP.Height = contentH
|
||||
}
|
||||
|
||||
func (d *DetailPane) splitHeights() (contentH, actionH int) {
|
||||
actionH = int(float64(d.height) * (1 - detailContentRatio))
|
||||
if actionH < 3 {
|
||||
actionH = 3
|
||||
}
|
||||
if actionH > 6 {
|
||||
actionH = 6
|
||||
}
|
||||
contentH = d.height - actionH - 1
|
||||
if contentH < 4 {
|
||||
contentH = 4
|
||||
}
|
||||
return contentH, actionH
|
||||
}
|
||||
|
||||
func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
|
||||
d.mode = detailForm
|
||||
d.item = item
|
||||
d.loadedID = item.ID
|
||||
d.actions = model.ActionsFor(item.Kind)
|
||||
d.actionIdx = 0
|
||||
d.form.Load(item.Kind, item.ID, fields)
|
||||
d.layoutContent()
|
||||
}
|
||||
|
||||
func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth int) {
|
||||
d.mode = detailDocument
|
||||
d.item = item
|
||||
d.loadedID = item.ID
|
||||
d.actions = model.ActionsFor(item.Kind)
|
||||
d.actionIdx = 0
|
||||
d.form.Clear()
|
||||
text, err := preview.RenderMarkdown(markdown, renderWidth)
|
||||
if err != nil {
|
||||
text = markdown
|
||||
}
|
||||
d.docText = text
|
||||
d.docVP.SetContent(text)
|
||||
d.layoutContent()
|
||||
}
|
||||
|
||||
func (d *DetailPane) layoutContent() {
|
||||
contentH, _ := d.splitHeights()
|
||||
d.form.SetSize(d.width, contentH)
|
||||
d.docVP.Width = d.width
|
||||
d.docVP.Height = contentH
|
||||
}
|
||||
|
||||
func (d *DetailPane) LoadedID() string { return d.loadedID }
|
||||
|
||||
func (d *DetailPane) Item() model.Item { return d.item }
|
||||
|
||||
func (d *DetailPane) Zone() detailZone { return d.zone }
|
||||
|
||||
func (d *DetailPane) FocusActions() {
|
||||
d.zone = detailZoneActions
|
||||
d.form.SetFocused(false)
|
||||
}
|
||||
|
||||
func (d *DetailPane) FocusContent() {
|
||||
d.zone = detailZoneContent
|
||||
d.form.SetFocused(d.focused && d.mode == detailForm)
|
||||
}
|
||||
|
||||
func (d *DetailPane) ToggleZone() {
|
||||
if d.zone == detailZoneContent {
|
||||
d.FocusActions()
|
||||
} else {
|
||||
d.FocusContent()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DetailPane) MoveAction(delta int) {
|
||||
if len(d.actions) == 0 {
|
||||
return
|
||||
}
|
||||
d.actionIdx = clampInt(d.actionIdx+delta, 0, len(d.actions)-1)
|
||||
}
|
||||
|
||||
func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
|
||||
if d.actionIdx < 0 || d.actionIdx >= len(d.actions) {
|
||||
return model.ItemAction{}, false
|
||||
}
|
||||
return d.actions[d.actionIdx], true
|
||||
}
|
||||
|
||||
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
|
||||
if !d.focused {
|
||||
return nil
|
||||
}
|
||||
if d.zone == detailZoneContent {
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
return d.form.Update(msg)
|
||||
case detailDocument:
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
switch msg.(tea.KeyMsg).String() {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
d.docVP.ViewDown()
|
||||
case "pgup", "b", "ctrl+u":
|
||||
d.docVP.ViewUp()
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
d.docVP, cmd = d.docVP.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DetailPane) View() string {
|
||||
if d.mode == detailEmpty {
|
||||
return d.styles.empty.Render("Select a row to preview or edit.")
|
||||
}
|
||||
contentH, actionH := d.splitHeights()
|
||||
var top string
|
||||
switch d.mode {
|
||||
case detailForm:
|
||||
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
|
||||
case detailDocument:
|
||||
top = d.docVP.View()
|
||||
default:
|
||||
top = ""
|
||||
}
|
||||
sep := d.styles.sep.Width(d.width).Render(strings.Repeat("─", max(1, d.width)))
|
||||
bottom := lipgloss.NewStyle().Width(d.width).Height(actionH).Render(d.renderActions())
|
||||
return lipgloss.JoinVertical(lipgloss.Left, top, sep, bottom)
|
||||
}
|
||||
|
||||
func (d DetailPane) renderActions() string {
|
||||
if len(d.actions) == 0 {
|
||||
hint := "No actions"
|
||||
if d.focused && d.zone == detailZoneActions {
|
||||
hint = lipgloss.NewStyle().Background(lipgloss.Color("238")).Render(" No actions ")
|
||||
}
|
||||
return d.styles.empty.Render(hint)
|
||||
}
|
||||
parts := make([]string, 0, len(d.actions))
|
||||
for i, act := range d.actions {
|
||||
style := d.styles.action
|
||||
if d.focused && d.zone == detailZoneActions && i == d.actionIdx {
|
||||
if act.Danger {
|
||||
style = d.styles.actionDn
|
||||
} else {
|
||||
style = d.styles.actionOn
|
||||
}
|
||||
} else if act.Danger {
|
||||
style = d.styles.actionDn
|
||||
}
|
||||
parts = append(parts, style.Render(act.Label))
|
||||
}
|
||||
line := strings.Join(parts, "")
|
||||
if d.focused && d.zone == detailZoneActions {
|
||||
line += d.styles.empty.Render(" ←/→ select · Enter run")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (d DetailPane) BlinkCmd() tea.Cmd {
|
||||
return entityFormBlinkCmd()
|
||||
}
|
||||
|
||||
// NewDetailPaneForTest exposes detail pane for tests.
|
||||
func NewDetailPaneForTest() DetailPane {
|
||||
return newDetailPane()
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package ui_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/ui"
|
||||
)
|
||||
|
||||
func TestEntityFormLoadAndView(t *testing.T) {
|
||||
form := ui.NewEntityFormForTest()
|
||||
form.SetSize(40, 16)
|
||||
form.Load(model.KindTask, "42", model.FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: "Fix bug", Secondary: "Details here",
|
||||
})
|
||||
view := form.View()
|
||||
if !strings.Contains(view, "Fix bug") {
|
||||
t.Fatal("view missing title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailPaneFormAndActions(t *testing.T) {
|
||||
d := ui.NewDetailPaneForTest()
|
||||
d.SetSize(50, 20)
|
||||
d.SetFocused(true)
|
||||
d.LoadForm(model.Item{ID: "1", Kind: model.KindTask, Title: "T"}, model.FormFields{
|
||||
PrimaryLabel: "Title", SecondaryLabel: "Description",
|
||||
Primary: "Hello", Secondary: "World",
|
||||
})
|
||||
view := d.View()
|
||||
if !strings.Contains(view, "Save") {
|
||||
t.Fatal("expected Save action button")
|
||||
}
|
||||
if !strings.Contains(view, "Hello") {
|
||||
t.Fatal("expected form content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailPaneDocumentMode(t *testing.T) {
|
||||
d := ui.NewDetailPaneForTest()
|
||||
d.SetSize(50, 20)
|
||||
d.LoadDocument(model.Item{ID: "9", Kind: model.KindFile, Title: "a.txt"}, "# Doc\n\nbody", 40)
|
||||
view := d.View()
|
||||
if !strings.Contains(view, "Download") {
|
||||
t.Fatal("expected Download action")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
type entityField int
|
||||
|
||||
const (
|
||||
entityFieldPrimary entityField = iota
|
||||
entityFieldSecondary
|
||||
)
|
||||
|
||||
// EntityForm is the top section of the detail pane for editable entities.
|
||||
type EntityForm struct {
|
||||
active bool
|
||||
kind model.Kind
|
||||
itemID string
|
||||
primaryLabel string
|
||||
secondaryLabel string
|
||||
readOnly bool
|
||||
primary textinput.Model
|
||||
secondary textarea.Model
|
||||
field entityField
|
||||
focused bool
|
||||
dirty bool
|
||||
width int
|
||||
height int
|
||||
styles entityFormStyles
|
||||
}
|
||||
|
||||
type entityFormStyles struct {
|
||||
header lipgloss.Style
|
||||
label lipgloss.Style
|
||||
labelAct lipgloss.Style
|
||||
}
|
||||
|
||||
func newEntityFormStyles() entityFormStyles {
|
||||
return entityFormStyles{
|
||||
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||
}
|
||||
}
|
||||
|
||||
func newEntityForm() EntityForm {
|
||||
primary := textinput.New()
|
||||
primary.CharLimit = 512
|
||||
primary.Prompt = "> "
|
||||
secondary := textarea.New()
|
||||
secondary.ShowLineNumbers = false
|
||||
secondary.CharLimit = 8000
|
||||
secondary.Prompt = "> "
|
||||
return EntityForm{
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
field: entityFieldPrimary,
|
||||
styles: newEntityFormStyles(),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *EntityForm) Active() bool { return f.active }
|
||||
|
||||
func (f *EntityForm) ItemID() string { return f.itemID }
|
||||
|
||||
func (f *EntityForm) Primary() string { return f.primary.Value() }
|
||||
|
||||
func (f *EntityForm) Secondary() string { return f.secondary.Value() }
|
||||
|
||||
func (f *EntityForm) MarkClean() { f.dirty = false }
|
||||
|
||||
func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormFields) {
|
||||
f.active = true
|
||||
f.kind = kind
|
||||
f.itemID = itemID
|
||||
f.primaryLabel = fields.PrimaryLabel
|
||||
f.secondaryLabel = fields.SecondaryLabel
|
||||
f.readOnly = fields.ReadOnly
|
||||
f.primary.SetValue(fields.Primary)
|
||||
f.secondary.SetValue(fields.Secondary)
|
||||
f.dirty = false
|
||||
f.field = entityFieldPrimary
|
||||
f.applyFocus()
|
||||
f.layoutFields()
|
||||
}
|
||||
|
||||
func (f *EntityForm) Clear() {
|
||||
f.active = false
|
||||
f.itemID = ""
|
||||
f.primary.SetValue("")
|
||||
f.secondary.SetValue("")
|
||||
f.dirty = false
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
}
|
||||
|
||||
func (f *EntityForm) SetFocused(on bool) {
|
||||
f.focused = on && f.active
|
||||
if f.focused {
|
||||
f.applyFocus()
|
||||
} else {
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *EntityForm) SetSize(w, h int) {
|
||||
if w < 12 {
|
||||
w = 12
|
||||
}
|
||||
if h < 6 {
|
||||
h = 6
|
||||
}
|
||||
f.width = w
|
||||
f.height = h
|
||||
f.layoutFields()
|
||||
}
|
||||
|
||||
func (f *EntityForm) layoutFields() {
|
||||
if f.width == 0 {
|
||||
return
|
||||
}
|
||||
inner := f.width - 2
|
||||
if inner < 8 {
|
||||
inner = 8
|
||||
}
|
||||
f.primary.Width = inner
|
||||
secH := f.height - 6
|
||||
if secH < 3 {
|
||||
secH = 3
|
||||
}
|
||||
f.secondary.SetWidth(inner)
|
||||
f.secondary.SetHeight(secH)
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusNext() {
|
||||
if f.field < entityFieldSecondary {
|
||||
f.field++
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) FocusPrev() {
|
||||
if f.field > entityFieldPrimary {
|
||||
f.field--
|
||||
}
|
||||
f.applyFocus()
|
||||
}
|
||||
|
||||
func (f *EntityForm) applyFocus() {
|
||||
if f.readOnly {
|
||||
f.primary.Blur()
|
||||
f.secondary.Blur()
|
||||
return
|
||||
}
|
||||
switch f.field {
|
||||
case entityFieldPrimary:
|
||||
f.primary.Focus()
|
||||
f.secondary.Blur()
|
||||
default:
|
||||
f.primary.Blur()
|
||||
f.secondary.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
|
||||
if !f.active || !f.focused || f.readOnly {
|
||||
return nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch f.field {
|
||||
case entityFieldPrimary:
|
||||
f.primary, cmd = f.primary.Update(msg)
|
||||
default:
|
||||
f.secondary, cmd = f.secondary.Update(msg)
|
||||
}
|
||||
if _, ok := msg.(tea.KeyMsg); ok {
|
||||
f.dirty = true
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (f EntityForm) View() string {
|
||||
if !f.active {
|
||||
return ""
|
||||
}
|
||||
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
|
||||
pLabel := f.styles.label.Render(f.primaryLabel)
|
||||
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
|
||||
pLabel = f.styles.labelAct.Render(f.primaryLabel)
|
||||
}
|
||||
sLabel := f.styles.label.Render(f.secondaryLabel)
|
||||
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
|
||||
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
|
||||
}
|
||||
ro := ""
|
||||
if f.readOnly {
|
||||
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
header + ro,
|
||||
pLabel,
|
||||
f.primary.View(),
|
||||
"",
|
||||
sLabel,
|
||||
f.secondary.View(),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func entityFormBlinkCmd() tea.Cmd {
|
||||
return tea.Batch(textinput.Blink, textarea.Blink)
|
||||
}
|
||||
|
||||
// NewEntityFormForTest exposes a form for unit tests.
|
||||
func NewEntityFormForTest() EntityForm {
|
||||
return newEntityForm()
|
||||
}
|
||||
@@ -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
|
||||
ActionMoveLeft
|
||||
ActionMoveRight
|
||||
ActionSort
|
||||
ActionToggleSelect
|
||||
ActionFocusDetail
|
||||
ActionToggleMenuPane
|
||||
ActionToggleListPane
|
||||
ActionToggleDetailPane
|
||||
ActionNextPane
|
||||
ActionPrevPane
|
||||
ActionRefresh
|
||||
ActionQuit
|
||||
)
|
||||
|
||||
// KeyAction maps a key string and focused pane to an action.
|
||||
func KeyAction(key string, pane model.FocusPane) Action {
|
||||
switch key {
|
||||
case "q", "ctrl+c":
|
||||
return ActionQuit
|
||||
case "tab":
|
||||
if pane != model.FocusPreview {
|
||||
return ActionNextPane
|
||||
}
|
||||
case "shift+tab", "backtab":
|
||||
if pane != model.FocusPreview {
|
||||
return ActionPrevPane
|
||||
}
|
||||
case "r":
|
||||
return ActionRefresh
|
||||
case "up", "k":
|
||||
return ActionMoveUp
|
||||
case "down", "j":
|
||||
return ActionMoveDown
|
||||
case "s":
|
||||
if pane == model.FocusList {
|
||||
return ActionSort
|
||||
}
|
||||
case "left", "h":
|
||||
return ActionMoveLeft
|
||||
case "right", "l":
|
||||
return ActionMoveRight
|
||||
case " ":
|
||||
if pane == model.FocusList {
|
||||
return ActionToggleSelect
|
||||
}
|
||||
case "v", "p":
|
||||
if pane == model.FocusList {
|
||||
return ActionFocusDetail
|
||||
}
|
||||
case "alt+1":
|
||||
return ActionToggleMenuPane
|
||||
case "alt+2":
|
||||
return ActionToggleListPane
|
||||
case "alt+3":
|
||||
return ActionToggleDetailPane
|
||||
}
|
||||
return ActionNone
|
||||
}
|
||||
|
||||
// LayoutWidthsLegacy is kept for tests that expect the old signature.
|
||||
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
|
||||
pw := LayoutWidths(total, defaultPaneVisibility())
|
||||
return pw.Menu, pw.List, pw.Detail
|
||||
}
|
||||
|
||||
// ResolveMoveUp returns the action for upward navigation keys.
|
||||
func ResolveMoveUp(pane model.FocusPane) Action {
|
||||
switch pane {
|
||||
case model.FocusMenu, model.FocusList, model.FocusPreview:
|
||||
return ActionMoveUp
|
||||
default:
|
||||
return ActionNone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ui
|
||||
|
||||
import "github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
|
||||
// PaneVisibility controls which columns are shown.
|
||||
type PaneVisibility struct {
|
||||
Menu, List, Detail bool
|
||||
}
|
||||
|
||||
// PaneWidths is outer lipgloss width per column (sums to terminal width).
|
||||
type PaneWidths struct {
|
||||
Menu, List, Detail int
|
||||
Visibility PaneVisibility
|
||||
}
|
||||
|
||||
func defaultPaneVisibility() PaneVisibility {
|
||||
return PaneVisibility{Menu: true, List: true, Detail: true}
|
||||
}
|
||||
|
||||
// LayoutWidths splits total terminal width evenly across visible panes.
|
||||
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
|
||||
if total < 1 {
|
||||
total = 80
|
||||
}
|
||||
v := vis
|
||||
n := countVisible(v)
|
||||
if n == 0 {
|
||||
v = defaultPaneVisibility()
|
||||
n = 3
|
||||
}
|
||||
base := total / n
|
||||
rem := total % n
|
||||
out := PaneWidths{Visibility: v}
|
||||
if v.Menu {
|
||||
out.Menu = base
|
||||
if rem > 0 {
|
||||
out.Menu++
|
||||
rem--
|
||||
}
|
||||
}
|
||||
if v.List {
|
||||
out.List = base
|
||||
if rem > 0 {
|
||||
out.List++
|
||||
rem--
|
||||
}
|
||||
}
|
||||
if v.Detail {
|
||||
out.Detail = base
|
||||
if rem > 0 {
|
||||
out.Detail++
|
||||
rem--
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countVisible(v PaneVisibility) int {
|
||||
n := 0
|
||||
if v.Menu {
|
||||
n++
|
||||
}
|
||||
if v.List {
|
||||
n++
|
||||
}
|
||||
if v.Detail {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// NextVisibleFocus cycles focus forward, skipping hidden panes.
|
||||
func NextVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
|
||||
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
|
||||
start := 0
|
||||
for i, p := range order {
|
||||
if p == cur {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for step := 1; step <= len(order); step++ {
|
||||
p := order[(start+step)%len(order)]
|
||||
if paneVisible(p, vis) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// PrevVisibleFocus cycles focus backward, skipping hidden panes.
|
||||
func PrevVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
|
||||
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
|
||||
start := 0
|
||||
for i, p := range order {
|
||||
if p == cur {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for step := 1; step <= len(order); step++ {
|
||||
p := order[(start-step+len(order))%len(order)]
|
||||
if paneVisible(p, vis) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func paneVisible(p model.FocusPane, vis PaneVisibility) bool {
|
||||
switch p {
|
||||
case model.FocusMenu:
|
||||
return vis.Menu
|
||||
case model.FocusList:
|
||||
return vis.List
|
||||
default:
|
||||
return vis.Detail
|
||||
}
|
||||
}
|
||||
|
||||
func firstVisibleFocus(vis PaneVisibility) model.FocusPane {
|
||||
if vis.Menu {
|
||||
return model.FocusMenu
|
||||
}
|
||||
if vis.List {
|
||||
return model.FocusList
|
||||
}
|
||||
return model.FocusPreview
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
|
||||
vis := PaneVisibility{Menu: true, List: true, Detail: true}
|
||||
pw := LayoutWidths(120, vis)
|
||||
sum := pw.Menu + pw.List + pw.Detail
|
||||
if sum != 120 {
|
||||
t.Fatalf("sum=%d want 120", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidthsSinglePane(t *testing.T) {
|
||||
pw := LayoutWidths(100, PaneVisibility{Menu: false, List: true, Detail: false})
|
||||
if pw.List != 100 {
|
||||
t.Fatalf("list=%d want 100", pw.List)
|
||||
}
|
||||
if pw.Menu != 0 || pw.Detail != 0 {
|
||||
t.Fatalf("unexpected widths: %+v", pw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidthsTwoPanes(t *testing.T) {
|
||||
pw := LayoutWidths(80, PaneVisibility{Menu: true, List: false, Detail: true})
|
||||
if pw.Menu+pw.Detail != 80 {
|
||||
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
|
||||
vis := PaneVisibility{Menu: false, List: true, Detail: true}
|
||||
if got := NextVisibleFocus(model.FocusList, vis); got != model.FocusPreview {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if got := NextVisibleFocus(model.FocusPreview, vis); got != model.FocusList {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
|
||||
vis := PaneVisibility{Menu: true, List: false, Detail: true}
|
||||
if got := PrevVisibleFocus(model.FocusPreview, vis); got != model.FocusMenu {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidthsLegacyHelper(t *testing.T) {
|
||||
menu, list, preview := LayoutWidthsLegacy(120)
|
||||
if menu+list+preview != 120 {
|
||||
t.Fatalf("widths exceed total")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionTogglePanes(t *testing.T) {
|
||||
if got := KeyAction("alt+1", model.FocusList); got != ActionToggleMenuPane {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if got := KeyAction("alt+2", model.FocusMenu); got != ActionToggleListPane {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if got := KeyAction("alt+3", model.FocusList); got != ActionToggleDetailPane {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func (m *Model) scrollFocusedPane(key string) bool {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
return scrollViewport(&m.menuVP, key)
|
||||
case model.FocusList:
|
||||
switch key {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
m.listTable.PageScroll(1)
|
||||
return true
|
||||
case "pgup", "b", "ctrl+u":
|
||||
m.listTable.PageScroll(-1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case model.FocusPreview:
|
||||
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument {
|
||||
return false
|
||||
}
|
||||
return scrollViewport(&m.detail.docVP, key)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) paneHeight() int {
|
||||
h := m.height - 3
|
||||
if h < 3 {
|
||||
h = 3
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Model) paneInnerWidth(outer int) int {
|
||||
if outer <= 0 {
|
||||
return 8
|
||||
}
|
||||
w := outer - 2 // border only; content fills inner box
|
||||
if w < 8 {
|
||||
w = 8
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func scrollViewport(vp *viewport.Model, key string) bool {
|
||||
switch key {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
vp.ViewDown()
|
||||
return true
|
||||
case "pgup", "b", "ctrl+u":
|
||||
vp.ViewUp()
|
||||
return true
|
||||
case "home", "g":
|
||||
vp.GotoTop()
|
||||
return true
|
||||
case "end", "G":
|
||||
vp.GotoBottom()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type tableStyles struct {
|
||||
title lipgloss.Style
|
||||
header lipgloss.Style
|
||||
headerSort lipgloss.Style
|
||||
cell lipgloss.Style
|
||||
rowActive lipgloss.Style
|
||||
colActive lipgloss.Style
|
||||
cellActive lipgloss.Style
|
||||
rowSelect lipgloss.Style
|
||||
cellSelect lipgloss.Style
|
||||
help lipgloss.Style
|
||||
}
|
||||
|
||||
func defaultTableStyles() tableStyles {
|
||||
return tableStyles{
|
||||
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
|
||||
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")).Padding(0, 1),
|
||||
headerSort: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Background(lipgloss.Color("62")).Padding(0, 1),
|
||||
cell: lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Padding(0, 1),
|
||||
rowActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("238")),
|
||||
colActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("237")),
|
||||
cellActive: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
|
||||
rowSelect: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("22")),
|
||||
cellSelect: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("34")),
|
||||
help: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
|
||||
}
|
||||
}
|
||||
|
||||
// DataTable is a spreadsheet-style listContent table for the list pane.
|
||||
type DataTable struct {
|
||||
spec model.ListSpec
|
||||
columns []model.TableColumn
|
||||
items []model.Item
|
||||
order []int
|
||||
cursorRow int
|
||||
cursorCol int
|
||||
colScroll int
|
||||
sortCol int
|
||||
sortAsc bool
|
||||
focused bool
|
||||
ready bool
|
||||
width int
|
||||
height int
|
||||
viewport viewport.Model
|
||||
styles tableStyles
|
||||
}
|
||||
|
||||
func newDataTable() DataTable {
|
||||
t := DataTable{
|
||||
sortCol: -1,
|
||||
styles: defaultTableStyles(),
|
||||
viewport: viewport.New(40, 10),
|
||||
}
|
||||
t.viewport.MouseWheelEnabled = true
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *DataTable) SetSize(w, h int) {
|
||||
if w < 8 {
|
||||
w = 8
|
||||
}
|
||||
if h < 3 {
|
||||
h = 3
|
||||
}
|
||||
t.width = w
|
||||
t.height = h
|
||||
t.viewport.Width = w
|
||||
t.viewport.Height = h - 2 // title + header
|
||||
if t.viewport.Height < 1 {
|
||||
t.viewport.Height = 1
|
||||
}
|
||||
t.refreshViewport()
|
||||
}
|
||||
|
||||
func (t *DataTable) SetFocused(f bool) {
|
||||
t.focused = f
|
||||
t.refreshViewport()
|
||||
}
|
||||
|
||||
func (t *DataTable) SetData(spec model.ListSpec, items []model.Item) {
|
||||
t.spec = spec
|
||||
t.items = items
|
||||
t.columns = model.BuildColumns(spec.Subject, items)
|
||||
t.order = make([]int, len(items))
|
||||
for i := range t.order {
|
||||
t.order[i] = i
|
||||
}
|
||||
t.cursorRow = 0
|
||||
t.cursorCol = 0
|
||||
t.colScroll = 0
|
||||
t.ready = true
|
||||
if t.sortCol >= 0 {
|
||||
t.applySort()
|
||||
}
|
||||
t.clampCursor()
|
||||
t.refreshViewport()
|
||||
}
|
||||
|
||||
func (t *DataTable) Clear() {
|
||||
t.ready = false
|
||||
t.items = nil
|
||||
t.order = nil
|
||||
t.columns = nil
|
||||
t.cursorRow = 0
|
||||
t.cursorCol = 0
|
||||
t.viewport.SetContent("")
|
||||
}
|
||||
|
||||
func (t *DataTable) ItemIndex() int {
|
||||
if t.cursorRow < 0 || t.cursorRow >= len(t.order) {
|
||||
return -1
|
||||
}
|
||||
return t.order[t.cursorRow]
|
||||
}
|
||||
|
||||
func (t *DataTable) MoveRow(delta int) {
|
||||
if !t.ready || len(t.order) == 0 {
|
||||
return
|
||||
}
|
||||
prev := t.cursorRow
|
||||
t.cursorRow = clampInt(t.cursorRow+delta, 0, len(t.order)-1)
|
||||
t.syncRowScroll(prev)
|
||||
t.refreshViewport()
|
||||
}
|
||||
|
||||
func (t *DataTable) MoveCol(delta int) {
|
||||
if !t.ready || len(t.columns) == 0 {
|
||||
return
|
||||
}
|
||||
t.cursorCol = clampInt(t.cursorCol+delta, 0, len(t.columns)-1)
|
||||
t.ensureColVisible()
|
||||
t.refreshViewport()
|
||||
}
|
||||
|
||||
func (t *DataTable) ToggleSort() {
|
||||
if !t.ready || len(t.columns) == 0 || len(t.order) == 0 {
|
||||
return
|
||||
}
|
||||
if t.sortCol == t.cursorCol {
|
||||
t.sortAsc = !t.sortAsc
|
||||
} else {
|
||||
t.sortCol = t.cursorCol
|
||||
t.sortAsc = true
|
||||
}
|
||||
t.applySort()
|
||||
t.refreshViewport()
|
||||
}
|
||||
|
||||
func (t *DataTable) applySort() {
|
||||
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
|
||||
return
|
||||
}
|
||||
key := t.columns[t.sortCol].Key
|
||||
sort.SliceStable(t.order, func(i, j int) bool {
|
||||
a := model.CellText(t.items[t.order[i]], key)
|
||||
b := model.CellText(t.items[t.order[j]], key)
|
||||
if t.sortAsc {
|
||||
return strings.ToLower(a) < strings.ToLower(b)
|
||||
}
|
||||
return strings.ToLower(a) > strings.ToLower(b)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *DataTable) PageScroll(delta int) {
|
||||
if delta > 0 {
|
||||
t.viewport.ViewDown()
|
||||
} else {
|
||||
t.viewport.ViewUp()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
|
||||
if !t.focused || !t.ready {
|
||||
return nil
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "pgdown", "pgdn", "f", "ctrl+d":
|
||||
t.viewport.ViewDown()
|
||||
case "pgup", "b", "ctrl+u":
|
||||
t.viewport.ViewUp()
|
||||
}
|
||||
case tea.MouseMsg:
|
||||
var cmd tea.Cmd
|
||||
t.viewport, cmd = t.viewport.Update(msg)
|
||||
return cmd
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t DataTable) View() string {
|
||||
if !t.ready {
|
||||
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
|
||||
}
|
||||
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
|
||||
sortHint := ""
|
||||
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
|
||||
dir := "▲"
|
||||
if !t.sortAsc {
|
||||
dir = "▼"
|
||||
}
|
||||
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
|
||||
}
|
||||
header := t.renderHeader()
|
||||
body := t.viewport.View()
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
|
||||
return lipgloss.NewStyle().Width(t.width).Render(content)
|
||||
}
|
||||
|
||||
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
|
||||
widths = make(map[int]int)
|
||||
if len(t.columns) == 0 || t.width <= 0 {
|
||||
return nil, widths
|
||||
}
|
||||
indices = t.pickVisibleColumnIndices()
|
||||
if len(indices) == 0 {
|
||||
return indices, widths
|
||||
}
|
||||
minSum := 0
|
||||
for _, i := range indices {
|
||||
minSum += t.columns[i].Width
|
||||
}
|
||||
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
|
||||
return indices, widths
|
||||
}
|
||||
|
||||
func (t *DataTable) pickVisibleColumnIndices() []int {
|
||||
var out []int
|
||||
used := 0
|
||||
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
|
||||
w := t.columns[colIdx].Width
|
||||
if len(out) > 0 && used+w > t.width {
|
||||
break
|
||||
}
|
||||
out = append(out, colIdx)
|
||||
used += w
|
||||
}
|
||||
if len(out) == 0 {
|
||||
colIdx := t.colScroll
|
||||
if colIdx < 0 || colIdx >= len(t.columns) {
|
||||
colIdx = 0
|
||||
}
|
||||
out = []int{colIdx}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
|
||||
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
|
||||
out := make(map[int]int, len(indices))
|
||||
if len(indices) == 0 {
|
||||
return out
|
||||
}
|
||||
if total < len(indices) {
|
||||
total = len(indices)
|
||||
}
|
||||
if minSum <= 0 {
|
||||
each := total / len(indices)
|
||||
if each < 1 {
|
||||
each = 1
|
||||
}
|
||||
for _, i := range indices {
|
||||
out[i] = each
|
||||
}
|
||||
fixColumnWidthSum(out, indices, total)
|
||||
return out
|
||||
}
|
||||
for _, i := range indices {
|
||||
out[i] = cols[i].Width
|
||||
}
|
||||
if minSum >= total {
|
||||
for _, i := range indices {
|
||||
out[i] = cols[i].Width * total / minSum
|
||||
if out[i] < 1 {
|
||||
out[i] = 1
|
||||
}
|
||||
}
|
||||
fixColumnWidthSum(out, indices, total)
|
||||
return out
|
||||
}
|
||||
extra := total - minSum
|
||||
flex := make([]int, 0, len(indices))
|
||||
for _, i := range indices {
|
||||
switch cols[i].Key {
|
||||
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks":
|
||||
flex = append(flex, i)
|
||||
}
|
||||
}
|
||||
if len(flex) == 0 {
|
||||
flex = append(flex, indices...)
|
||||
}
|
||||
flexSum := 0
|
||||
for _, i := range flex {
|
||||
flexSum += cols[i].Width
|
||||
}
|
||||
if flexSum <= 0 {
|
||||
flexSum = len(flex)
|
||||
}
|
||||
for _, i := range flex {
|
||||
out[i] += extra * cols[i].Width / flexSum
|
||||
}
|
||||
fixColumnWidthSum(out, indices, total)
|
||||
return out
|
||||
}
|
||||
|
||||
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
sum := 0
|
||||
for _, i := range indices {
|
||||
sum += widths[i]
|
||||
}
|
||||
widths[indices[len(indices)-1]] += total - sum
|
||||
if widths[indices[len(indices)-1]] < 1 {
|
||||
widths[indices[len(indices)-1]] = 1
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DataTable) renderHeader() string {
|
||||
indices, widths := t.visibleLayout()
|
||||
cells := make([]string, 0, len(indices))
|
||||
for _, colIdx := range indices {
|
||||
col := t.columns[colIdx]
|
||||
w := widths[colIdx]
|
||||
title := col.Title
|
||||
if t.sortCol == colIdx {
|
||||
if t.sortAsc {
|
||||
title += " ▲"
|
||||
} else {
|
||||
title += " ▼"
|
||||
}
|
||||
}
|
||||
text := runewidth.Truncate(title, w, "…")
|
||||
style := t.styles.header
|
||||
if t.focused && colIdx == t.cursorCol {
|
||||
style = t.styles.headerSort
|
||||
}
|
||||
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||
}
|
||||
|
||||
func (t *DataTable) refreshViewport() {
|
||||
if !t.ready {
|
||||
return
|
||||
}
|
||||
rows := make([]string, 0, len(t.order))
|
||||
for row := 0; row < len(t.order); row++ {
|
||||
rows = append(rows, t.renderRow(row))
|
||||
}
|
||||
t.viewport.SetContent(strings.Join(rows, "\n"))
|
||||
t.syncRowScroll(t.cursorRow)
|
||||
}
|
||||
|
||||
func (t *DataTable) renderRow(row int) string {
|
||||
item := t.items[t.order[row]]
|
||||
selected := item.Selected
|
||||
indices, widths := t.visibleLayout()
|
||||
cells := make([]string, 0, len(indices))
|
||||
for _, colIdx := range indices {
|
||||
col := t.columns[colIdx]
|
||||
w := widths[colIdx]
|
||||
text := runewidth.Truncate(model.CellText(item, col.Key), w, "…")
|
||||
cells = append(cells, t.styleCell(row, colIdx, selected, text, w))
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
|
||||
}
|
||||
|
||||
func (t *DataTable) styleCell(row, col int, selected bool, text string, width int) string {
|
||||
base := t.styles.cell.Width(width).MaxWidth(width)
|
||||
isRow := row == t.cursorRow
|
||||
isCol := col == t.cursorCol
|
||||
isCell := t.focused && isRow && isCol
|
||||
|
||||
switch {
|
||||
case selected && isCell:
|
||||
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text)
|
||||
case selected && isRow:
|
||||
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
|
||||
case selected:
|
||||
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
|
||||
case isCell:
|
||||
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text)
|
||||
case t.focused && isRow:
|
||||
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text)
|
||||
case t.focused && isCol:
|
||||
return t.styles.colActive.Width(width).MaxWidth(width).Render(text)
|
||||
default:
|
||||
return base.Render(text)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DataTable) ensureColVisible() {
|
||||
vis := t.pickVisibleColumnIndices()
|
||||
if len(vis) == 0 {
|
||||
return
|
||||
}
|
||||
first, last := vis[0], vis[len(vis)-1]
|
||||
if t.cursorCol < first {
|
||||
t.colScroll = t.cursorCol
|
||||
}
|
||||
if t.cursorCol > last {
|
||||
t.colScroll = t.cursorCol
|
||||
for t.cursorCol >= 0 {
|
||||
vis = t.pickVisibleColumnIndices()
|
||||
if len(vis) == 0 {
|
||||
break
|
||||
}
|
||||
last = vis[len(vis)-1]
|
||||
if t.cursorCol <= last {
|
||||
break
|
||||
}
|
||||
if t.colScroll < len(t.columns)-1 {
|
||||
t.colScroll++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *DataTable) syncRowScroll(prev int) {
|
||||
if t.cursorRow < t.viewport.YOffset {
|
||||
t.viewport.YOffset = t.cursorRow
|
||||
} else if t.cursorRow >= t.viewport.YOffset+t.viewport.Height {
|
||||
t.viewport.YOffset = t.cursorRow - t.viewport.Height + 1
|
||||
}
|
||||
if t.viewport.YOffset < 0 {
|
||||
t.viewport.YOffset = 0
|
||||
}
|
||||
maxOff := len(t.order) - t.viewport.Height
|
||||
if maxOff < 0 {
|
||||
maxOff = 0
|
||||
}
|
||||
if t.viewport.YOffset > maxOff {
|
||||
t.viewport.YOffset = maxOff
|
||||
}
|
||||
_ = prev
|
||||
}
|
||||
|
||||
func (t *DataTable) clampCursor() {
|
||||
if len(t.order) == 0 {
|
||||
t.cursorRow = 0
|
||||
} else {
|
||||
t.cursorRow = clampInt(t.cursorRow, 0, len(t.order)-1)
|
||||
}
|
||||
if len(t.columns) == 0 {
|
||||
t.cursorCol = 0
|
||||
} else {
|
||||
t.cursorCol = clampInt(t.cursorCol, 0, len(t.columns)-1)
|
||||
}
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// tableKeyMatches helps avoid importing key in app.go for sort-only bindings.
|
||||
func (t *DataTable) UpdateItems(items []model.Item) {
|
||||
t.items = items
|
||||
t.clampCursor()
|
||||
t.refreshViewport()
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func sampleItems() []model.Item {
|
||||
return []model.Item{
|
||||
{ID: "2", Title: "Bravo", Subtitle: "b", Kind: model.KindTask, Raw: map[string]any{"status": "Open"}},
|
||||
{ID: "1", Title: "Alpha", Subtitle: "a", Kind: model.KindTask, Raw: map[string]any{"status": "Done"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableSortByTitle(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(80, 12)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||
tbl.cursorCol = indexOfColumn(tbl, "title")
|
||||
tbl.ToggleSort()
|
||||
if model.CellText(tbl.items[tbl.order[0]], "title") != "Alpha" {
|
||||
t.Fatalf("expected Alpha first after sort, order=%v", tbl.order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableMoveColScrolls(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(24, 10)
|
||||
items := make([]model.Item, 1)
|
||||
items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
|
||||
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d",
|
||||
}}
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
|
||||
last := len(tbl.columns) - 1
|
||||
tbl.cursorCol = last
|
||||
tbl.ensureColVisible()
|
||||
if tbl.colScroll == 0 && last > 2 {
|
||||
t.Fatalf("expected horizontal scroll for wide table, colScroll=%d", tbl.colScroll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableViewHighlightsActiveCell(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(80, 12)
|
||||
tbl.SetFocused(true)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||
tbl.cursorRow = 1
|
||||
tbl.cursorCol = 2
|
||||
view := tbl.View()
|
||||
if !strings.Contains(view, "Alpha") {
|
||||
t.Fatal("view missing row data")
|
||||
}
|
||||
if !strings.Contains(view, "Title") {
|
||||
t.Fatal("view missing header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTableFillsPaneWidth(t *testing.T) {
|
||||
tbl := newDataTable()
|
||||
tbl.SetSize(80, 12)
|
||||
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
|
||||
indices, widths := tbl.visibleLayout()
|
||||
sum := 0
|
||||
for _, i := range indices {
|
||||
sum += widths[i]
|
||||
}
|
||||
if sum != 80 {
|
||||
t.Fatalf("visible columns width sum=%d want 80", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
|
||||
cols := []model.TableColumn{
|
||||
{Key: "_sel", Width: 3},
|
||||
{Key: "id", Width: 10},
|
||||
{Key: "title", Width: 20},
|
||||
}
|
||||
indices := []int{0, 1, 2}
|
||||
w := distributeColumnWidths(33, 80, indices, cols)
|
||||
sum := w[0] + w[1] + w[2]
|
||||
if sum != 80 {
|
||||
t.Fatalf("sum=%d want 80", sum)
|
||||
}
|
||||
if w[2] <= 20 {
|
||||
t.Fatalf("title should expand, got %d", w[2])
|
||||
}
|
||||
}
|
||||
|
||||
func indexOfColumn(tbl DataTable, key string) int {
|
||||
for i, c := range tbl.columns {
|
||||
if c.Key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¤cy, "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
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package main
|
||||
|
||||
// Build metadata injected at release time via -ldflags (GoReleaser).
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
)
|
||||
@@ -0,0 +1,580 @@
|
||||
package onlyoffice
|
||||
|
||||
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
|
||||
// These expose untyped maps for flexibility — they are primarily consumed by
|
||||
// cmd/oo and the applications-sync workflow.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
if search != "" {
|
||||
q.Set("filterValue", search)
|
||||
}
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var env struct {
|
||||
Response []map[string]any `json:"response"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := env.Total
|
||||
if total == 0 && len(env.Response) > 0 {
|
||||
total = len(env.Response)
|
||||
}
|
||||
return env.Response, total, nil
|
||||
}
|
||||
|
||||
// GetContact returns a single contact by id.
|
||||
func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
|
||||
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||
}
|
||||
|
||||
// FindCompany searches for a company contact with an exact (case-insensitive)
|
||||
// displayName match. Returns nil when not found.
|
||||
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
|
||||
needle := CompanyGroupingKey(name)
|
||||
const page = 50
|
||||
for start := 0; ; start += page {
|
||||
items, total, err := c.ListContacts(ctx, page, start, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, co := range items {
|
||||
if !isCompany(co) {
|
||||
continue
|
||||
}
|
||||
if CompanyGroupingKey(fmt.Sprint(co["displayName"])) == needle {
|
||||
return co, nil
|
||||
}
|
||||
}
|
||||
if start+page >= total || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// FindPerson searches for a person by first+last (case-insensitive).
|
||||
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range items {
|
||||
if isCompany(p) {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(fmt.Sprint(p["firstName"])) == firstNeedle &&
|
||||
strings.ToLower(fmt.Sprint(p["lastName"])) == lastNeedle {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if start+page >= total || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func isCompany(m map[string]any) bool {
|
||||
v, ok := m["isCompany"].(bool)
|
||||
return ok && v
|
||||
}
|
||||
|
||||
// CreateCompany creates a company contact with the given name.
|
||||
func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) {
|
||||
fields := url.Values{}
|
||||
fields.Set("companyName", name)
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/contact/company.json", fields)
|
||||
}
|
||||
|
||||
// CreatePerson creates a person contact; companyID == 0 means unlinked.
|
||||
func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
|
||||
fields := url.Values{}
|
||||
fields.Set("firstName", first)
|
||||
fields.Set("lastName", last)
|
||||
if companyID != 0 {
|
||||
fields.Set("companyId", strconv.Itoa(companyID))
|
||||
}
|
||||
if jobTitle != "" {
|
||||
fields.Set("jobTitle", jobTitle)
|
||||
}
|
||||
if about != "" {
|
||||
fields.Set("about", about)
|
||||
}
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
|
||||
}
|
||||
|
||||
// AddContactInfo attaches an email/website/phone/etc. to a contact.
|
||||
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
|
||||
if category == "" {
|
||||
category = "Work"
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("infoType", infoType)
|
||||
fields.Set("data", dataValue)
|
||||
fields.Set("category", category)
|
||||
fields.Set("isPrimary", strconv.FormatBool(isPrimary))
|
||||
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields)
|
||||
}
|
||||
|
||||
// DeleteContact removes a CRM contact by id.
|
||||
func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[string]any, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, chunk...)
|
||||
if start+page >= total || len(chunk) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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.
|
||||
func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/opportunity/filter.json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var env struct {
|
||||
Response []map[string]any `json:"response"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return env.Response, env.Total, nil
|
||||
}
|
||||
|
||||
// GetOpportunity returns a single opportunity (deal) by id.
|
||||
func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// CreateOpportunity creates a new deal. An empty responsibleID falls back to
|
||||
// the authenticated user's id, and bidCurrency defaults to "EUR".
|
||||
func (c *Client) CreateOpportunity(ctx context.Context, title string, stageID int, responsibleID, bidCurrency, description string, bidValue float64) (map[string]any, error) {
|
||||
if responsibleID == "" {
|
||||
s, err := c.SelfUserID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responsibleID = s
|
||||
}
|
||||
if bidCurrency == "" {
|
||||
bidCurrency = "EUR"
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("stageId", strconv.Itoa(stageID))
|
||||
fields.Set("responsibleId", responsibleID)
|
||||
fields.Set("bidCurrencyAbbr", bidCurrency)
|
||||
if bidValue != 0 {
|
||||
fields.Set("bidValue", strconv.FormatFloat(bidValue, 'g', -1, 64))
|
||||
}
|
||||
if description != "" {
|
||||
fields.Set("description", description)
|
||||
}
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/opportunity.json", fields)
|
||||
}
|
||||
|
||||
// AddOpportunityMember links a contact to an opportunity.
|
||||
func (c *Client) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
|
||||
return c.postFormObject(ctx,
|
||||
fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)),
|
||||
url.Values{})
|
||||
}
|
||||
|
||||
// ListDealStages returns the configured opportunity stages.
|
||||
func (c *Client) ListDealStages(ctx context.Context) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, "/api/2.0/crm/opportunity/stage.json")
|
||||
}
|
||||
|
||||
// DeleteOpportunity removes a deal by id.
|
||||
func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if members := OpportunityMembers(opp); len(members) > 0 {
|
||||
return members, 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.
|
||||
func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[string]any, int, error) {
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/case/filter.json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var env struct {
|
||||
Response []map[string]any `json:"response"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return env.Response, env.Total, nil
|
||||
}
|
||||
|
||||
// CreateCase creates a new CRM case.
|
||||
func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) {
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/case.json", fields)
|
||||
}
|
||||
|
||||
// AddCaseMember links a contact to a CRM case.
|
||||
func (c *Client) AddCaseMember(ctx context.Context, caseID, contactID string) (map[string]any, error) {
|
||||
return c.postFormObject(ctx,
|
||||
fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)),
|
||||
url.Values{})
|
||||
}
|
||||
|
||||
// DeleteCase removes a case by id.
|
||||
func (c *Client) DeleteCase(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
q := url.Values{}
|
||||
q.Set("count", strconv.Itoa(count))
|
||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/task/filter.json?"+q.Encode())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var env struct {
|
||||
Response []map[string]any `json:"response"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return env.Response, env.Total, nil
|
||||
}
|
||||
|
||||
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
|
||||
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
|
||||
fields := url.Values{}
|
||||
fields.Set("title", title)
|
||||
fields.Set("deadline", deadline)
|
||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||
if contactID != 0 {
|
||||
fields.Set("contactId", strconv.Itoa(contactID))
|
||||
}
|
||||
if entityType != "" {
|
||||
fields.Set("entityType", entityType)
|
||||
}
|
||||
if entityID != 0 {
|
||||
fields.Set("entityId", strconv.Itoa(entityID))
|
||||
}
|
||||
if description != "" {
|
||||
fields.Set("description", description)
|
||||
}
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
|
||||
}
|
||||
|
||||
// DeleteCRMTask removes a CRM task by id.
|
||||
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||
}
|
||||
|
||||
// ListTaskCategories returns CRM task categories.
|
||||
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
||||
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
||||
}
|
||||
|
||||
// AddHistoryNote attaches a history note to a CRM entity. When categoryID is 0,
|
||||
// the cached "note" category id is looked up from /api/2.0/crm/history/category.json.
|
||||
func (c *Client) AddHistoryNote(ctx context.Context, entityType string, entityID int, content string, categoryID int) (map[string]any, error) {
|
||||
if categoryID == 0 {
|
||||
id, err := c.historyNoteCategoryID(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categoryID = id
|
||||
}
|
||||
fields := url.Values{}
|
||||
fields.Set("entityType", entityType)
|
||||
fields.Set("entityId", strconv.Itoa(entityID))
|
||||
fields.Set("content", content)
|
||||
fields.Set("categoryId", strconv.Itoa(categoryID))
|
||||
return c.postFormObject(ctx, "/api/2.0/crm/history.json", fields)
|
||||
}
|
||||
|
||||
func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) {
|
||||
if c.noteCatID != 0 {
|
||||
return c.noteCatID, nil
|
||||
}
|
||||
list, err := c.ResponseArray(ctx, "/api/2.0/crm/history/category.json")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, row := range list {
|
||||
if strings.EqualFold(fmt.Sprint(row["title"]), "note") {
|
||||
c.noteCatID = int(flexInt(row["id"]))
|
||||
return c.noteCatID, nil
|
||||
}
|
||||
}
|
||||
if len(list) > 0 {
|
||||
c.noteCatID = int(flexInt(list[0]["id"]))
|
||||
return c.noteCatID, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// flexInt coerces OnlyOffice numeric fields that JSON unmarshal may surface as
|
||||
// float64, int, json.Number, or string into a plain int64.
|
||||
func flexInt(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
default:
|
||||
n, _ := strconv.ParseInt(fmt.Sprint(x), 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
+272
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package main shows how to list calendars and events, and create a new event
|
||||
// via the OnlyOffice Calendar API.
|
||||
//
|
||||
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||
// export ONLYOFFICE_USER="admin@example.com"
|
||||
// export ONLYOFFICE_PASS="your-password"
|
||||
// # optional: default calendar for AddEvent
|
||||
// export ONLYOFFICE_CALENDAR_ID="42"
|
||||
// go run ./examples/calendar
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
)
|
||||
|
||||
func main() {
|
||||
creds := onlyoffice.GetEnvironmentCredentials()
|
||||
if creds.Url == "" {
|
||||
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client := onlyoffice.NewClient(creds)
|
||||
client.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
||||
|
||||
ctx := context.Background()
|
||||
start := time.Now().Format("2006-01-02")
|
||||
end := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
|
||||
|
||||
calendars, err := client.ListCalendars(ctx, start, end)
|
||||
if err != nil {
|
||||
log.Fatalf("list calendars: %v", err)
|
||||
}
|
||||
fmt.Printf("Calendars (%d):\n", len(calendars))
|
||||
for _, cal := range calendars {
|
||||
fmt.Printf(" - id=%v title=%v\n", cal["objectId"], cal["title"])
|
||||
}
|
||||
|
||||
events, err := client.ListEvents(ctx, start, end)
|
||||
if err != nil {
|
||||
log.Fatalf("list events: %v", err)
|
||||
}
|
||||
fmt.Printf("\nEvents in [%s..%s]: %d\n", start, end, len(events))
|
||||
for _, e := range events {
|
||||
fmt.Printf(" - %v @ %v\n", e["title"], e["start"])
|
||||
}
|
||||
|
||||
// Create demo event — uncomment to exercise.
|
||||
// evStart := time.Now().Add(time.Hour).Format(time.RFC3339)
|
||||
// evEnd := time.Now().Add(2 * time.Hour).Format(time.RFC3339)
|
||||
// created, err := client.AddEvent(ctx, "", "Library demo", evStart, evEnd, "via examples/calendar", false)
|
||||
// if err != nil {
|
||||
// log.Fatalf("add event: %v", err)
|
||||
// }
|
||||
// fmt.Printf("created event id=%v\n", created["objectId"])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package main lists CRM contacts and opportunities, and shows how to create
|
||||
// and remove a demo company + opportunity + history note in OnlyOffice CRM.
|
||||
//
|
||||
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||
// export ONLYOFFICE_USER="admin@example.com"
|
||||
// export ONLYOFFICE_PASS="your-password"
|
||||
// go run ./examples/crm
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
)
|
||||
|
||||
func main() {
|
||||
creds := onlyoffice.GetEnvironmentCredentials()
|
||||
if creds.Url == "" {
|
||||
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client := onlyoffice.NewClient(creds)
|
||||
ctx := context.Background()
|
||||
|
||||
contacts, total, err := client.ListContacts(ctx, 10, 0, "")
|
||||
if err != nil {
|
||||
log.Fatalf("list contacts: %v", err)
|
||||
}
|
||||
fmt.Printf("CRM contacts: %d (total=%d)\n", len(contacts), total)
|
||||
for _, c := range contacts {
|
||||
fmt.Printf(" - id=%v name=%v\n", c["id"], c["displayName"])
|
||||
}
|
||||
|
||||
opps, oppTotal, err := client.ListOpportunities(ctx, 10, 0)
|
||||
if err != nil {
|
||||
log.Fatalf("list opportunities: %v", err)
|
||||
}
|
||||
fmt.Printf("\nOpportunities: %d (total=%d)\n", len(opps), oppTotal)
|
||||
for _, o := range opps {
|
||||
fmt.Printf(" - id=%v title=%v\n", o["id"], o["title"])
|
||||
}
|
||||
|
||||
// Full create/attach/delete cycle — uncomment to exercise.
|
||||
// company, err := client.CreateCompany(ctx, "Demo GmbH")
|
||||
// if err != nil { log.Fatalf("create company: %v", err) }
|
||||
// cid := fmt.Sprint(company["id"])
|
||||
// defer client.DeleteContact(ctx, cid)
|
||||
//
|
||||
// stages, err := client.ListDealStages(ctx)
|
||||
// if err != nil || len(stages) == 0 { log.Fatalf("no deal stages: %v", err) }
|
||||
// stageID := int(stages[0]["id"].(float64))
|
||||
//
|
||||
// opp, err := client.CreateOpportunity(ctx, "Demo opp", stageID, "", "EUR", "from examples/crm", 0)
|
||||
// if err != nil { log.Fatalf("create opportunity: %v", err) }
|
||||
// oid := fmt.Sprint(opp["id"])
|
||||
// _, _ = client.AddOpportunityMember(ctx, oid, cid)
|
||||
// _, _ = client.AddHistoryNote(ctx, "opportunity", int(opp["id"].(float64)), "Touched via library example", 0)
|
||||
// _, _ = client.DeleteOpportunity(ctx, oid)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package main creates a parent task and attaches two subtasks using the
|
||||
// project-tasks API (POST /api/2.0/project/task/{id}.json).
|
||||
//
|
||||
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||
// export ONLYOFFICE_USER="admin@example.com"
|
||||
// export ONLYOFFICE_PASS="your-password"
|
||||
// export ONLYOFFICE_PROJECT_ID="123"
|
||||
// go run ./examples/subtasks
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
)
|
||||
|
||||
func main() {
|
||||
creds := onlyoffice.GetEnvironmentCredentials()
|
||||
defaults := onlyoffice.GetEnvironmentDefaults()
|
||||
if creds.Url == "" || defaults.ProjectID == "" {
|
||||
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL and ONLYOFFICE_PROJECT_ID must be set")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client := onlyoffice.NewClient(creds)
|
||||
client.SetDefaults(defaults)
|
||||
ctx := context.Background()
|
||||
|
||||
parent, err := client.AddTask(ctx, defaults.ProjectID, "Example parent", "Created by examples/subtasks", 0, "")
|
||||
if err != nil {
|
||||
log.Fatalf("add parent task: %v", err)
|
||||
}
|
||||
parentID := fmt.Sprint(parent["id"])
|
||||
fmt.Printf("parent task id=%s\n", parentID)
|
||||
|
||||
for _, title := range []string{"first subtask", "second subtask"} {
|
||||
st, err := client.AddSubtask(ctx, parentID, title)
|
||||
if err != nil {
|
||||
log.Fatalf("add subtask %q: %v", title, err)
|
||||
}
|
||||
fmt.Printf(" + subtask id=%v title=%v\n", st["id"], st["title"])
|
||||
}
|
||||
|
||||
// Cleanup — uncomment once you verified the subtasks in the OnlyOffice UI.
|
||||
// if _, err := client.DeleteTask(ctx, parentID); err != nil {
|
||||
// log.Printf("cleanup: %v", err)
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package onlyoffice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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.
|
||||
// Returns the decoded "response" object from OnlyOffice.
|
||||
func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) {
|
||||
p := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID))
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := responseField(raw, "response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 || string(resp) == "null" {
|
||||
return &ProjectFilesResponse{}, nil
|
||||
}
|
||||
var out ProjectFilesResponse
|
||||
if err := json.Unmarshal(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,45 @@
|
||||
module github.com/eslider/go-onlyoffice
|
||||
|
||||
go 1.22.2
|
||||
go 1.25.0
|
||||
|
||||
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/joho/godotenv v1.5.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
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/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/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
|
||||
)
|
||||
|
||||
@@ -1,6 +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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-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/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/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/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=
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package onlyoffice
|
||||
|
||||
// Transport-layer helpers used by the untyped domain methods (CRM, calendar,
|
||||
// tasks, files). Authentication lives in auth.go; the typed Request/Query
|
||||
// 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// baseURL returns the configured base URL without trailing slash.
|
||||
func (c *Client) baseURL() string {
|
||||
return strings.TrimRight(c.credentials.Url, "/")
|
||||
}
|
||||
|
||||
// truncate crops s to n runes, appending "..." when truncated. Used in error
|
||||
// messages to keep OnlyOffice HTML payloads readable.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// responseField extracts a top-level JSON key (for example "response") from a
|
||||
// raw OnlyOffice envelope. Returns an error when the key is missing.
|
||||
func responseField(raw json.RawMessage, key string) (json.RawMessage, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("response missing %q", key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ResponseArray executes a GET and returns the "response" field as []map.
|
||||
// Returns (nil, nil) when the field is JSON null.
|
||||
func (c *Client) ResponseArray(ctx context.Context, path string) ([]map[string]any, error) {
|
||||
raw, err := c.getJSON(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := responseField(raw, "response")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(resp) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(resp, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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.
|
||||
func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) {
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
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("GET %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// postForm issues an authenticated POST with application/x-www-form-urlencoded body.
|
||||
func (c *Client) postForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
|
||||
return c.formRequest(ctx, http.MethodPost, path, fields)
|
||||
}
|
||||
|
||||
// putForm issues an authenticated PUT with application/x-www-form-urlencoded body.
|
||||
func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
|
||||
return c.formRequest(ctx, http.MethodPut, path, fields)
|
||||
}
|
||||
|
||||
func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, strings.NewReader(fields.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
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("%s form %s: %d %s", method, path, resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// deleteReq issues an authenticated DELETE.
|
||||
func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, error) {
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// putJSONObject issues an authenticated PUT with JSON body and decodes response.
|
||||
func (c *Client) putJSONObject(ctx context.Context, path string, body any) (map[string]any, error) {
|
||||
raw, err := c.putJSON(ctx, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalResponseObject(raw)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
var rdr io.Reader
|
||||
switch b := body.(type) {
|
||||
case nil:
|
||||
rdr = strings.NewReader("{}")
|
||||
case []byte:
|
||||
rdr = bytes.NewReader(b)
|
||||
case string:
|
||||
rdr = strings.NewReader(b)
|
||||
default:
|
||||
buf, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.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.
|
||||
func (c *Client) uploadMultipart(ctx context.Context, path, fieldName, filePath string) (json.RawMessage, error) {
|
||||
auth, err := c.authHeader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, err := mw.CreateFormFile(fieldName, filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(part, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
-650
@@ -1,650 +0,0 @@
|
||||
package onlyoffice
|
||||
|
||||
// OnlyOffice client package
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/go-querystring/query"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
regexp "regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewClient API
|
||||
func NewClient(c Credentials) *Client {
|
||||
return &Client{
|
||||
client: http.DefaultClient,
|
||||
credentials: &c,
|
||||
}
|
||||
}
|
||||
|
||||
// GetEnvironmentCredentials when using environment variables
|
||||
func GetEnvironmentCredentials() Credentials {
|
||||
return Credentials{
|
||||
Url: os.Getenv("ONLYOFFICE_URL"),
|
||||
User: os.Getenv("ONLYOFFICE_USER"),
|
||||
Password: os.Getenv("ONLYOFFICE_PASS"),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return fmt.Sprintf(*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.Code is not nil then it will be marshaled to JSON
|
||||
// - If request.Token is nil then it will get a 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
|
||||
func (c *Client) Query(request Request, result interface{}) (err error) {
|
||||
var url = fmt.Sprintf("%s%s", c.credentials.Url, request.Uri)
|
||||
var rdr io.Reader = nil
|
||||
var jsonRequestBody string
|
||||
|
||||
// Add query parameters if available
|
||||
if request.Params != nil {
|
||||
v, err := query.Values(request.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url = fmt.Sprintf("%s?%s", url, v.Encode())
|
||||
}
|
||||
|
||||
// Create request
|
||||
if request.Body != nil {
|
||||
// Check request body if type is not []byte or string the marshal it to JSON
|
||||
switch request.Body.(type) {
|
||||
case []byte:
|
||||
jsonRequestBody = string(request.Body.([]byte))
|
||||
rdr = strings.NewReader(string(request.Body.([]byte)))
|
||||
case string:
|
||||
jsonRequestBody = request.Body.(string)
|
||||
rdr = strings.NewReader(request.Body.(string))
|
||||
default:
|
||||
// Marshal to JSON
|
||||
b, err := json.Marshal(request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %v", err)
|
||||
}
|
||||
jsonRequestBody = string(b)
|
||||
rdr = strings.NewReader(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
// Get token if not set?
|
||||
if !request.NoAuth {
|
||||
// Get token if not set or expired
|
||||
if c.token == nil || time.Time(c.token.Expires).Before(time.Now()) {
|
||||
c.token, err = c.Auth(c.credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to authenticate: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set token if available
|
||||
if request.Token != nil {
|
||||
req.Header.Set("Authorization", *request.Token)
|
||||
} else {
|
||||
// Set token from a client if available
|
||||
if 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: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if request.Debug {
|
||||
// Rewind reader
|
||||
_ = jsonRequestBody
|
||||
var buf = new(strings.Builder)
|
||||
io.Copy(buf, resp.Body)
|
||||
js := buf.String()
|
||||
return json.Unmarshal([]byte(js), result)
|
||||
} else {
|
||||
// Unmarshal response using reader
|
||||
return json.NewDecoder(resp.Body).Decode(result)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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), Debug: false},
|
||||
&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,
|
||||
Debug: true,
|
||||
}, &struct {
|
||||
//MetaResponse `json:",inline"`
|
||||
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,
|
||||
Debug: true,
|
||||
},
|
||||
&struct {
|
||||
//MetaResponse `json:",inline"`
|
||||
Response *[]*Task `json:"response"`
|
||||
}{&tasks})
|
||||
}
|
||||
+206
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
Vendored
+25
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"count": 1,
|
||||
"status": 0,
|
||||
"statusCode": 200,
|
||||
"response": [
|
||||
{
|
||||
"id": 200,
|
||||
"title": "attach.pdf",
|
||||
"fileExst": ".pdf",
|
||||
"contentLength": "1024",
|
||||
"fileType": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package onlyoffice
|
||||
|
||||
// Pure unit tests — no network, no fake vendor HTTP servers.
|
||||
// Protocol-level behaviour is covered by *_integration_test.go (build-tagged).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResponseFieldMissing(t *testing.T) {
|
||||
if _, err := responseField(json.RawMessage(`{"other":1}`), "response"); err == nil {
|
||||
t.Fatal("expected error on missing field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseFieldPresent(t *testing.T) {
|
||||
raw, err := responseField(json.RawMessage(`{"response":[1,2,3],"other":9}`), "response")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "[1,2,3]" {
|
||||
t.Errorf("got %s", string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBodyReaderNil(t *testing.T) {
|
||||
r, err := requestBodyReader(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Errorf("expected nil reader for nil body, got %T", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBodyReaderBytes(t *testing.T) {
|
||||
r, err := requestBodyReader([]byte(`{"k":1}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("nil reader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBodyReaderStruct(t *testing.T) {
|
||||
type payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
r, err := requestBodyReader(payload{Name: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 64)
|
||||
n, _ := r.Read(buf)
|
||||
if string(buf[:n]) != `{"name":"x"}` {
|
||||
t.Errorf("got %q", string(buf[:n]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectStringNilSafe(t *testing.T) {
|
||||
var p Project
|
||||
if got := p.String(); got != "" {
|
||||
t.Errorf("nil Title should yield empty string, got %q", got)
|
||||
}
|
||||
title := "my project"
|
||||
p.Title = &title
|
||||
if got := p.String(); got != "my project" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
titleWithPercent := "100% coverage"
|
||||
p.Title = &titleWithPercent
|
||||
if got := p.String(); got != "100% coverage" {
|
||||
t.Errorf("Sprintf format-string regression: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateContextRespectsCancellation(t *testing.T) {
|
||||
// Point the client at a routable-but-unresponsive endpoint (TEST-NET-1
|
||||
// per RFC 5737) and cancel the context almost immediately. The test
|
||||
// verifies ctx plumbing, not OnlyOffice protocol — no vendor mock.
|
||||
c := NewClient(Credentials{Url: "http://192.0.2.1:9", User: "u", Password: "p"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := c.AuthenticateContext(ctx); err == nil {
|
||||
t.Fatal("expected error on context timeout against unreachable endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateTokenIsIdempotent(t *testing.T) {
|
||||
c := NewClient(Credentials{Url: "http://example.invalid", User: "u", Password: "p"})
|
||||
c.InvalidateToken()
|
||||
c.InvalidateToken()
|
||||
if c.token != nil {
|
||||
t.Fatal("token should remain nil after double invalidate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvironmentCredentialsAliases(t *testing.T) {
|
||||
t.Setenv("ONLYOFFICE_URL", "")
|
||||
t.Setenv("ONLYOFFICE_HOST", "https://example/")
|
||||
t.Setenv("ONLYOFFICE_USER", "")
|
||||
t.Setenv("ONLYOFFICE_NAME", "alice")
|
||||
t.Setenv("ONLYOFFICE_PASS", "")
|
||||
t.Setenv("ONLYOFFICE_PASSWORD", "s3cret")
|
||||
c := GetEnvironmentCredentials()
|
||||
if c.Url != "https://example" {
|
||||
t.Errorf("Url alias not applied / trailing slash not trimmed: %q", c.Url)
|
||||
}
|
||||
if c.User != "alice" {
|
||||
t.Errorf("User alias not applied: %q", c.User)
|
||||
}
|
||||
if c.Password != "s3cret" {
|
||||
t.Errorf("Password alias not applied: %q", c.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvironmentDefaultsFallbacks(t *testing.T) {
|
||||
t.Setenv("ONLYOFFICE_CALENDAR_ID", "")
|
||||
t.Setenv("ONLYOFFICE_PROJECT_ID", "")
|
||||
t.Setenv("ONLYOFFICE_CALENDAR_PROJECT_ID", "")
|
||||
d := GetEnvironmentDefaults()
|
||||
if d.CalendarID != "1" || d.ProjectID != "33" {
|
||||
t.Errorf("defaults: %+v", d)
|
||||
}
|
||||
t.Setenv("ONLYOFFICE_CALENDAR_PROJECT_ID", "7")
|
||||
if got := GetEnvironmentDefaults().ProjectID; got != "7" {
|
||||
t.Errorf("CalendarProjectId alias: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user