Compare commits

...
26 Commits
Author SHA1 Message Date
eSliderandCursor fab2977cb1 feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Polish the office TUI with proportional pane layout, drag resize, live
filtering, HTML mail preview, paginated mail lists, overflow scrollbars,
flattened navigation, and project save/status fixes; add UpdateProjectStatus.

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

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

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

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

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

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

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

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

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

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

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

http.go: putJSON for JSON PUT bodies.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-24 11:30:11 +01:00
eSlider 0e70467522 Add badges: pkg.go.dev, license, Go version, release, stars 2026-02-13 14:02:49 +01:00
120 changed files with 15313 additions and 754 deletions
+38
View File
@@ -0,0 +1,38 @@
---
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
globs:
- "**/*_test.go"
alwaysApply: false
---
# Testing policy — no synthetic vendor mockups
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
other third-party API. Simulated vendor responses drift from reality, give
false green signals, and hide protocol changes.
## What to do instead
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
struct conversions, pure helpers. No `httptest` that fakes the vendor.
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
file. Read credentials from env:
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
regular `go test ./...` stays green in CI.
3. **Run integration**: `go test -tags=integration ./...`.
4. **Every new endpoint** ships with an integration test in the same PR.
## Narrow exception
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
OK when the test server is pretending to be OnlyOffice or Gitea.
## Migrating existing tests
If you find a test that handles routes like `/api/2.0/...` and returns canned
JSON, convert it to an integration test (or delete it if the behaviour is
already covered by integration).
+27
View File
@@ -0,0 +1,27 @@
# OnlyOffice Workspace credentials. Used by cmd/oo (the library itself
# does not load .env files — only its CLI does, via godotenv).
#
# Primary names:
ONLYOFFICE_URL=https://office.example.com
ONLYOFFICE_USER=
ONLYOFFICE_PASS=
# Accepted aliases (only one of each pair is needed):
# ONLYOFFICE_HOST=
# ONLYOFFICE_NAME=
# ONLYOFFICE_PASSWORD=
#
# 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=
+28
View File
@@ -0,0 +1,28 @@
name: Release Please
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
issues: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
steps:
- name: Run release-please
uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
+39
View File
@@ -0,0 +1,39 @@
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Git tag to release (e.g. v0.4.0)
required: true
type: string
permissions:
contents: write
jobs:
goreleaser:
name: GoReleaser
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+49
View File
@@ -0,0 +1,49 @@
name: Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
name: Test (Go ${{ matrix.go }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go: ["1.22", "stable"]
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
cache: true
- name: Verify dependencies
run: |
go mod verify
go mod download
- name: Check go mod tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum
- name: Vet
run: go vet ./...
- name: Build oo
run: go build -trimpath -buildvcs=false -o /tmp/oo ./cmd/oo
- name: Build office
run: go build -trimpath -buildvcs=false -o /tmp/office ./cmd/office
- name: Run tests
run: go test -race -shuffle=on -count=1 ./...
+11
View File
@@ -0,0 +1,11 @@
# Local environment
.env
# Go workspace (for local multi-module dev; see inventar ASR-0008)
go.work
go.work.sum
# Build artifacts (top-level compiled binaries; cmd/oo/ source tree is tracked)
/oo
/oo-cli
/bin/
+124
View File
@@ -0,0 +1,124 @@
version: 2
project_name: go-onlyoffice
before:
hooks:
- go mod tidy
- go mod verify
builds:
- id: oo
main: ./cmd/oo
binary: oo
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
- id: office
main: ./cmd/office
binary: office
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
flags:
- -trimpath
- -buildvcs=false
archives:
- id: oo
ids: [oo]
name_template: "oo_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
- id: office
ids: [office]
name_template: "office_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- CHANGELOG.md
checksum:
name_template: checksums.txt
algorithm: sha256
snapshot:
version_template: "{{ .Tag }}-next"
changelog:
use: github
sort: asc
groups:
- title: Features
regexp: '^.*?feat(\(.+\))??!?:.+$'
order: 0
- title: Bug fixes
regexp: '^.*?fix(\(.+\))??!?:.+$'
order: 1
- title: Performance
regexp: '^.*?perf(\(.+\))??!?:.+$'
order: 2
- title: Others
order: 999
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
- "^style:"
- "^build:"
- "Merge pull request"
- "Merge branch"
release:
draft: false
prerelease: auto
mode: replace
header: |
## Release {{ .Tag }}
Install:
```sh
go install github.com/eslider/go-onlyoffice/cmd/oo@{{ .Tag }}
go install github.com/eslider/go-onlyoffice/cmd/office@{{ .Tag }}
```
Or download prebuilt binaries below.
footer: |
**Full Changelog**: https://github.com/eSlider/go-onlyoffice/compare/{{ .PreviousTag }}...{{ .Tag }}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.6.0"
}
+57
View File
@@ -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.
+335
View File
@@ -0,0 +1,335 @@
# 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.7.0] — 2026-06-24
### Added — `office` TUI
- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes.
- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears.
- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane.
- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list.
- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport.
- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d).
- **Tasks** — humanized status labels and relative deadlines in the list.
- **Projects** — open/closed row colors; status toggle in the detail form; **Save** sends `responsibleId` and status via `UpdateProjectStatus`.
### Changed — `office` TUI
- Flattened navigation: **Projects**, **Tasks**, **By project**, **Calendar**, **CRM**, **Mail**, **Users** (removed Browse; Calendar no longer splits Calendars/Events).
- Middle pane truncates overflowing cells with `…`; full text on the cursor or Space-selected row.
- Detail form tab order: Title → Description → Status → Save → Delete.
- **j/k** scrolls mail/file preview and read-only detail forms; mouse wheel scrolls detail when hovering the content area.
### Added — library
- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle.
## [0.5.1] — 2026-06-24
### Added — `office` TUI
- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
- Visible panes share **100% terminal width** evenly; pane content fills its column.
- Tab focus skips hidden panes.
### Changed — `office` TUI
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
- Middle pane: multi-column table with sort, selection, and full-width columns.
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
- Row selection auto-loads detail; files show document preview, entities show forms.
## [0.6.0] - 2026-04-24
### Added — library
- **Project & task Documents API** in [`files.go`](files.go):
- `FileEntry`, `FolderEntry`, `ProjectFilesResponse` types.
- `GetProjectFiles`, `GetTaskFiles`, `GetFile`.
- `UploadProjectFile` — `POST /api/2.0/files/{folderId}/upload` into the
project's `projectFolder` (resolved via `GetProjectByID` or first folder
from `GetProjectFiles`).
- `AttachFilesToTask` — `POST .../project/task/{id}/files` with form
`files=<id>` (OnlyOffice expects **existing** file ids, not multipart).
- `UploadTaskFile` — uploads via `UploadProjectFile` using the task's
`projectOwner.id`, then attaches.
- `DetachTaskFile` — `DELETE .../files?fileid=`.
- `RenameFile` — `PUT /api/2.0/files/file/{id}.json` with JSON body.
- `DeleteFiles` — `PUT /api/2.0/files/fileops/delete.json` with `fileIds`.
- `DownloadFile` — `GetFile` then `GET` on `viewUrl` with `Authorization`.
- Helpers: `FileEntryNumericID`, `FileEntryTitle`, `SafeLocalFileName`.
- **`putJSON`** on `*Client` in [`http.go`](http.go) for JSON PUT bodies.
### Added — CLI
- `oo projects files list|upload|download|rename|delete` — see
[`cmd/oo/projects_files.go`](cmd/oo/projects_files.go); `list` supports
`--folders`.
- `oo tasks files list|upload|detach` — see [`cmd/oo/tasks_files.go`](cmd/oo/tasks_files.go).
### Added — tests
- [`files_integration_test.go`](files_integration_test.go) — live roundtrip
against OnlyOffice (same credential rules as `client_test.go`).
- [`files_test.go`](files_test.go) + `testdata/*.json` — envelope decode unit
tests (no network).
## [0.5.0] - 2026-04-24
### Changed — CLI **BREAKING**
- **Subject-based command tree** (`tea`-style). The flat `oo verb-noun`
layout is replaced with `oo <subject> <verb>`:
| Old | New |
|---|---|
| `oo cal-list` | `oo calendar list` |
| `oo cal-events` | `oo calendar events` |
| `oo cal-add` / `cal-delete` | `oo calendar add` / `calendar delete` |
| `oo task-list` | `oo tasks list` |
| `oo task-add` | `oo tasks create` |
| `oo task-update` | `oo tasks update` (deletion moved to `oo tasks delete`) |
| `oo subtask-add` | `oo tasks subtask add` |
| `oo crm-contacts` | `oo contacts list` (plus filtered `oo persons list` / `oo companies list`) |
| `oo crm-add-contact --company …` | `oo companies create --name …` |
| `oo crm-add-contact --person-first …` | `oo persons create --first …` |
| `oo crm-deals` | `oo opportunities list` |
| `oo crm-deals --stages` | `oo opportunities stages` |
| `oo crm-add-deal` | `oo opportunities create` |
| `oo crm-cases` | `oo cases list` |
| `oo applications-sync` | `oo applications sync` |
- **New subjects**: `oo projects {list,get,milestones,create,update,delete}`,
`oo users {list,self}` (plus top-level `oo whoami`), `oo crm-tasks
{list,create,delete,categories}`, `oo cases {create,delete,member-add}`,
`oo contacts {get,info-add}`.
- **Global `--output/-o` flag**: all list-like commands now support
`--output table` (default; tabwriter-aligned, truncated to 80 chars
per cell) and `--output json`. Nested `bidCurrency` flattened to its
`abbreviation` in the table view.
- **Module aliases**: `oo calendar|cal`, `oo projects|prj`, `oo tasks|task`,
`oo persons|person`, `oo companies|company`, `oo opportunities|deals|deal`,
`oo cases|case`, `oo applications|apps`. `delete|rm` on every leaf that
removes things.
### Added
- `cmd/oo/common.go` — shared `printTable(headers, rows)` and
`printObject(v)` helpers that dispatch on the `--output` flag.
- `cmd/oo/users.go` — exposes `oo users list`, `oo users self`, `oo whoami`
via the library's `GetUsers` + `SelfUserID`.
- `cmd/oo/projects.go` — full CRUD for projects backed by `CreateProject`,
`UpdateProject`, `DeleteProject`, `GetProjectByID`, `GetProjectMilestones`.
- `cmd/oo/crm_tasks.go` — dedicated `oo crm-tasks` subject (distinct from
project `oo tasks`).
- `cmd/oo/contacts.go` — unified contacts/persons/companies with shared
list/filter implementation.
### Changed — library (minor)
- `newOO` in `cmd/oo/common.go` now calls `AuthenticateContext(cmd.Context())`
so CLI aborts propagate to the auth request.
## [0.4.0] - 2026-04-24
### Changed — project structure
- **Library files reorganised by domain** (mechanical split; zero API surface
change). The former monolithic `onlyoffice.go` (687 LOC) is now split into:
- `client.go` — `Client`, `Credentials`, `Defaults`, env helpers, `NewClient`.
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`,
`Permissions`, `requestBodyReader`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`,
`Auth`, `ensureToken`, `authHeader`, `tokenValid`.
- `http.go` — transport helpers and DRY response decoders
(`ResponseArray`, `ResponseObject`, `postFormObject`, `putFormObject`,
`deleteObject`, `unmarshalResponseObject`).
- `projects.go` — `Project`, `Projects`, `Milestone`, `ProjectOwner` +
`GetProjects` / `CreateProject` / `UpdateProject` / `DeleteProject` /
`GetProjectByID` / `GetProjectMilestones`.
- `tasks.go` — `Task`, `ProjectTaskStatus`, `TaskPriority`,
`ProjectGetTasksRequest` et al. **plus** the form-encoded helpers
formerly in `tasks_extra.go` (`ListTasks`, `AddTask`, `AddSubtask`,
`UpdateTaskStatus`, `DeleteTask`, `GetTaskByID`).
- `users.go` — `User`, `Contact`, `Group`, `GetUsers`, `SelfUserID`.
- `calendar.go`, `crm.go`, `files.go` — unchanged in scope, refactored
through the new DRY helpers.
- `httpx.go` → **renamed** `http.go`.
- `onlyoffice.go` and `tasks_extra.go` — **deleted** (content redistributed).
### Changed — CLI **BREAKING**
- **Binary renamed `oo-cli` → `oo`.** Install with
`go install github.com/eslider/go-onlyoffice/cmd/oo@latest`.
- **Package path `cmd/oo-cli` → `cmd/oo`.** The old path is removed.
- **`internal/cli` is gone.** Cobra commands now live directly under
`cmd/oo/` as `package main`, split by domain: `calendar.go`, `crm.go`,
`tasks.go`, `apps.go`, `common.go`. Rationale: cobra wiring is a CLI-only
concern and does not belong inside a `pkg-level internal/`.
- **`internal/applications` → `cmd/oo/applications/`.** This is a
CV-specific CRM workflow — not a general OnlyOffice feature — and is only
consumed by the `oo` CLI. Keeping it under `cmd/oo/` prevents accidental
external adoption and makes the coupling explicit.
- **`examples/applications/` removed.** It imported an internal package,
which was a policy smell. Remaining examples (`basic`, `calendar`, `crm`,
`subtasks`) use only the exported library surface.
### Added
- `(*Client).ResponseObject` — GET-and-decode-object counterpart to the
existing `ResponseArray`.
- `(*Client).postFormObject` / `putFormObject` / `deleteObject` — eliminate
the ~15 identical "form request → `responseField` → `json.Unmarshal`"
blocks previously duplicated across `crm.go` / `tasks_extra.go` /
`calendar.go` / `files.go`.
### Migration
External library consumers: **no changes required**. The module path
(`github.com/eslider/go-onlyoffice`), the `onlyoffice` package name, and
every exported symbol are unchanged.
CLI users: replace `oo-cli` with `oo` in scripts and CI. The command set
and flags are identical.
## [0.3.2] - 2026-04-24
### Fixed
- `Project.String()` no longer interprets the title as a format string
(`fmt.Sprintf(*p.Title)`) and is now nil-safe on a zero-value `Project`.
- `internal/applications.buildSummary` no longer panics at regex compile time
on Go 1.23+ — the previous `(?= ...)` lookahead is replaced with an RE2-safe
non-capturing trailing delimiter.
### Changed
- `Client.Query` now routes token acquisition through the shared
`ensureToken` path instead of duplicating the auth-expiry check inline.
- Request body marshalling is consolidated into an unexported
`requestBodyReader` helper (DRY; no change to the public surface).
- The `Request.Debug` field is preserved for backwards compatibility but no
longer changes behaviour — both branches used to unmarshal into the same
target value. We'll remove the field in a future major release.
### Tests
- Deleted `httptest.NewServer` fixtures that emulated OnlyOffice protocol
endpoints. Replaced them with:
- pure-Go unit tests in `unit_test.go` (no network);
- real integration tests in `client_test.go` guarded by
`//go:build integration`. Run with
`go test -tags=integration ./...`. Tests skip cleanly when
`ONLYOFFICE_URL/USER/PASS` (or aliases) are absent.
- New policy documented in `AGENTS.md` and
`.cursor/rules/no-synthetic-mocks.mdc`.
## [0.3.1] - 2026-04-24
### Added
- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and
deadlines. Preferred entry point for long-running syncs (cron, watchers).
- `InvalidateToken()` — clears the cached token to force re-auth on the next
request. Use this to recover from a mid-sync 401 when the server has revoked
the session while the local `Expires` timestamp still looks fresh.
### Notes
- Plain `Authenticate()` is unchanged and remains a convenience wrapper around
`AuthenticateContext(context.Background())`.
- No breaking changes; a patch release.
## [0.3.0] - 2026-04-24
### Added
- Calendar helpers: `ListCalendars`, `ListEvents`, `AddEvent`, `DeleteEvent`.
- CRM helpers: contacts (`ListContacts`, `GetContact`, `FindCompany`, `FindPerson`,
`CreateCompany`, `CreatePerson`, `AddContactInfo`, `DeleteContact`), deals
(`ListOpportunities`, `GetOpportunity`, `CreateOpportunity`,
`AddOpportunityMember`, `ListDealStages`, `DeleteOpportunity`), cases
(`ListCases`, `CreateCase`, `AddCaseMember`, `DeleteCase`), CRM tasks
(`ListCRMTasks`, `CreateCRMTask`, `DeleteCRMTask`, `ListTaskCategories`), and
history notes (`AddHistoryNote`).
- Project task extras: `GetProjectByID`, `ListTasks`, `ListAllTasks`,
`GetTaskByID`, `AddTask`, `AddSubtask`, `UpdateTaskStatus`, `DeleteTask`.
- File upload: `UploadOpportunityFile` (multipart).
- `SelfUserID` cached lookup of `people/@self`.
- `Defaults` struct + `SetDefaults` + `GetEnvironmentDefaults` for optional
calendar/project fallbacks.
- Alias env vars accepted by `GetEnvironmentCredentials`:
`ONLYOFFICE_HOST` / `ONLYOFFICE_NAME` / `ONLYOFFICE_PASSWORD`.
- Public `Authenticate()` that primes the token eagerly.
- Bundled CLI: `cmd/oo-cli` (Cobra) with commands `cal-list`, `cal-events`,
`cal-add`, `cal-delete`, `task-list`, `task-add`, `subtask-add`,
`task-update`, `crm-contacts`, `crm-add-contact`, `crm-deals`,
`crm-add-deal`, `crm-cases`, `applications-sync`.
- httptest-based unit tests for form / multipart / CRM helpers.
### Changed
- The `onlyoffice.Client` struct gained unexported fields (`defaults`, `selfID`,
`noteCatID`); the public API is unchanged and remains backwards compatible.
## [0.2.0] - earlier
- Badges, docs expansion (Gitea sync use case, Gantt/PM workflows).
## [0.1.0] - earlier
- Initial release: OnlyOffice Project Management API client.
+373 -3
View File
@@ -1,5 +1,12 @@
# go-onlyoffice
[![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-onlyoffice.svg)](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8.svg)](https://go.dev)
[![Tests](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml/badge.svg)](https://github.com/eSlider/go-onlyoffice/actions/workflows/test.yml)
[![Latest Release](https://img.shields.io/github/v/tag/eSlider/go-onlyoffice?sort=semver&label=release)](https://github.com/eSlider/go-onlyoffice/releases)
[![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-onlyoffice?style=social)](https://github.com/eSlider/go-onlyoffice/stargazers)
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically.
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
+121
View File
@@ -0,0 +1,121 @@
package onlyoffice
// Authentication primitives: token lifecycle, eager / context-aware auth, and
// token invalidation. Split out of http.go so auth concerns live together.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Auth authenticates using the given credentials and returns a fresh token.
// Most callers should not call this directly — use Authenticate /
// AuthenticateContext, or let Query fetch a token lazily. Kept exported for
// backwards compatibility.
func (c *Client) Auth(creds *Credentials) (*Token, error) {
t := &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: http.MethodPost,
Body: creds,
NoAuth: true,
}, &struct {
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token
// is fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron,
// watchers) because it guarantees that a stalled auth call will not block
// the caller past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.tokenValid() {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// tokenValid reports whether the cached token is present and not expired.
func (c *Client) tokenValid() bool {
return c.token != nil && !time.Time(c.token.Expires).Before(time.Now())
}
// ensureToken refreshes the authentication token when missing or expired.
// Mirrors the logic inline in Query() but is safe to call from helpers that
// bypass the typed Request abstraction.
func (c *Client) ensureToken() error {
if c.tokenValid() {
return nil
}
tok, err := c.Auth(c.credentials)
if err != nil {
return err
}
c.token = tok
return nil
}
// authHeader returns the value for the Authorization header, ensuring a token.
func (c *Client) authHeader() (string, error) {
if err := c.ensureToken(); err != nil {
return "", err
}
return c.token.Value, nil
}
+72
View File
@@ -0,0 +1,72 @@
package onlyoffice
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
// Empty start/end fall back to a wide default window (2020-01-01..2030-12-31),
// matching the Python reference implementation.
func (c *Client) ListCalendars(ctx context.Context, start, end string) ([]map[string]any, error) {
if start == "" {
start = "2020-01-01"
}
if end == "" {
end = "2030-12-31"
}
path := fmt.Sprintf("/api/2.0/calendar/calendars/%s/%s.json", url.PathEscape(start), url.PathEscape(end))
return c.ResponseArray(ctx, path)
}
// ListEvents is an alias for ListCalendars; retained for the Python-port CLI.
func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[string]any, error) {
return c.ListCalendars(ctx, start, end)
}
// AddEvent creates a simple one-shot calendar event. When calendarID is empty
// the configured default (SetDefaults or GetEnvironmentDefaults) is used.
// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601).
//
// The underlying API returns an array wrapper — this helper unwraps the first
// element for convenience.
func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) {
if calendarID == "" {
calendarID = c.defaults.CalendarID
}
if calendarID == "" {
return nil, fmt.Errorf("AddEvent: calendarID is required (pass explicitly or set via SetDefaults)")
}
fields := url.Values{}
fields.Set("name", title)
fields.Set("description", description)
fields.Set("startDate", start)
fields.Set("endDate", end)
fields.Set("repeatType", "")
fields.Set("alertType", "0")
fields.Set("isAllDayLong", strconv.FormatBool(allDay))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var arr []map[string]any
if err := json.Unmarshal(resp, &arr); err != nil {
return nil, err
}
if len(arr) == 0 {
return nil, fmt.Errorf("AddEvent: empty response")
}
return arr[0], nil
}
// DeleteEvent removes a single calendar event by its event ID.
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
}
+108
View File
@@ -0,0 +1,108 @@
// Package onlyoffice provides a Go client for the OnlyOffice Workspace
// (formerly ONLYOFFICE) REST API. It is organised as a flat single package
// intentionally: a single *Client exposes Projects, Tasks, Calendar, CRM and
// Files operations as receiver methods. Domain split is by file, not by
// subpackage, to keep call sites uniform (c.ListContacts, c.GetTasks,
// c.AddEvent, c.UploadOpportunityFile all live on the same handle).
//
// The CLI binary `oo` (see cmd/oo) is a thin cobra wrapper on top of this
// library and mirrors the Python cv/bin/office reference tooling.
package onlyoffice
import (
"net/http"
"os"
"strings"
)
// Client of OnlyOffice API uses credentials to get a token and query the API
// by every request.
//
// Construct with NewClient; optionally set fallbacks via SetDefaults. The
// zero value is NOT usable — credentials are required. A single client is
// safe for sequential use from one goroutine; for concurrent use, callers
// should wrap with their own synchronization or create one client per
// goroutine.
type Client struct {
client *http.Client
credentials *Credentials
token *Token
defaults Defaults // optional fallbacks for calendar/project IDs
selfID string // cached /api/2.0/people/@self id
noteCatID int // cached CRM history category id for "note"
}
// NewClient returns a new Client backed by http.DefaultClient.
func NewClient(c Credentials) *Client {
return &Client{
client: http.DefaultClient,
credentials: &c,
}
}
// Credentials of OnlyOffice User. The Url field is NOT sent with the auth
// payload — it only determines the host.
type Credentials struct {
Url string `json:"-"`
User string `json:"userName"`
Password string `json:"password"`
}
// Defaults holds optional fallbacks used by package-level helpers when callers
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults
// or read from env via GetEnvironmentDefaults.
type Defaults struct {
CalendarID string
ProjectID string
}
// SetDefaults configures optional identifiers used as fallbacks by methods
// such as AddEvent (when calendarID == "") or AddTask (when projectID == "").
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
//
// Primary variables (documented):
// - ONLYOFFICE_URL
// - ONLYOFFICE_USER
// - ONLYOFFICE_PASS
//
// Additional aliases accepted for interoperability with sibling tools:
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
func GetEnvironmentCredentials() Credentials {
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
url = strings.TrimRight(url, "/")
return Credentials{
Url: url,
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
}
}
// GetEnvironmentDefaults reads optional library defaults from environment:
//
// - ONLYOFFICE_CALENDAR_ID (default: "1")
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
func GetEnvironmentDefaults() Defaults {
return Defaults{
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
ProjectID: firstNonEmpty(
os.Getenv("ONLYOFFICE_PROJECT_ID"),
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
"33",
),
}
}
// firstNonEmpty returns the first non-empty trimmed value, or "" if none.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
+173 -81
View File
@@ -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)
}
}
+50
View File
@@ -0,0 +1,50 @@
// Package bootstrap loads CLI environment and constructs an authenticated
// OnlyOffice client. Shared by cmd/oo and cmd/office; the library never
// loads dotfiles.
package bootstrap
import (
"context"
"fmt"
"os"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
)
// LoadEnv reads .env from the current working directory and applies OO_* aliases.
func LoadEnv() {
_ = godotenv.Load(".env")
applyEnvAliases()
}
// NewClient loads env, validates credentials, and authenticates against OnlyOffice.
func NewClient(ctx context.Context) (*onlyoffice.Client, error) {
LoadEnv()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST/OO_URL), user (ONLYOFFICE_USER or ONLYOFFICE_NAME/OO_USER), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD/OO_PASS)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.AuthenticateContext(ctx); err != nil {
return nil, err
}
return c, nil
}
func applyEnvAliases() {
setEnvIfEmpty("ONLYOFFICE_URL", "OO_URL")
setEnvIfEmpty("ONLYOFFICE_USER", "OO_USER")
setEnvIfEmpty("ONLYOFFICE_PASS", "OO_PASS")
}
func setEnvIfEmpty(dst, src string) {
if strings.TrimSpace(os.Getenv(dst)) != "" {
return
}
if v := strings.TrimSpace(os.Getenv(src)); v != "" {
_ = os.Setenv(dst, v)
}
}
+95
View File
@@ -0,0 +1,95 @@
package bootstrap_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
)
func TestLoadEnvFromCWDWithOOAliases(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS", "OO_URL", "OO_USER", "OO_PASS")
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
if err := os.WriteFile(".env", []byte("OO_URL=https://office.example.com\nOO_USER=user@example.com\nOO_PASS=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://office.example.com" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
if got := os.Getenv("ONLYOFFICE_USER"); got != "user@example.com" {
t.Fatalf("ONLYOFFICE_USER=%q", got)
}
if got := os.Getenv("ONLYOFFICE_PASS"); got != "secret" {
t.Fatalf("ONLYOFFICE_PASS=%q", got)
}
}
func TestLoadEnvDoesNotOverrideCanonicalWithAlias(t *testing.T) {
clearEnv(t, "ONLYOFFICE_URL", "OO_URL")
t.Setenv("ONLYOFFICE_URL", "https://canonical.example")
t.Setenv("OO_URL", "https://alias.example")
bootstrap.LoadEnv()
if got := os.Getenv("ONLYOFFICE_URL"); got != "https://canonical.example" {
t.Fatalf("ONLYOFFICE_URL=%q", got)
}
}
func TestNewClientReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
_, err = bootstrap.NewClient(context.Background())
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+53
View File
@@ -0,0 +1,53 @@
package fetch
import (
"context"
"fmt"
"time"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// CalendarItemFields maps calendar API rows to list items.
var CalendarItemFields = FieldMap{IDKey: "objectId", TitleKey: "title"}
// CalendarItemsFromRows converts API calendar/event rows into list items.
func CalendarItemsFromRows(rows []map[string]any) []model.Item {
out := make([]model.Item, 0, len(rows))
for _, row := range rows {
kind, typeLabel := model.ClassifyCalendarRow(row)
title := str(row, "title")
if title == "" {
title = str(row, "name")
}
if title == "" {
title = "(untitled)"
}
raw := make(map[string]any, len(row)+1)
for k, v := range row {
raw[k] = v
}
raw["type"] = typeLabel
out = append(out, model.Item{
ID: idStr(row, "objectId"),
Title: title,
Kind: kind,
Raw: raw,
})
}
return out
}
// ListCalendar returns calendars and events for the upcoming date window.
func (l *Loader) ListCalendar(ctx context.Context) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
start := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
end := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
rows, err := l.Client.ListCalendars(ctx, start, end)
if err != nil {
return nil, err
}
return CalendarItemsFromRows(rows), nil
}
+107
View File
@@ -0,0 +1,107 @@
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func skipWithoutLiveAPI(t *testing.T) {
t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
if os.Getenv("ONLYOFFICE_USER") == "" && os.Getenv("ONLYOFFICE_NAME") == "" {
t.Skip("ONLYOFFICE_USER not set")
}
if os.Getenv("ONLYOFFICE_PASS") == "" && os.Getenv("ONLYOFFICE_PASSWORD") == "" {
t.Skip("ONLYOFFICE_PASS not set")
}
}
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
t.Helper()
skipWithoutLiveAPI(t)
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
return &fetch.Loader{Client: client}, context.Background()
}
func TestIntegrationListAllSubjects(t *testing.T) {
loader, ctx := liveLoader(t)
cases := []model.ListSpec{
{Subject: model.SubjectProjects},
{Subject: model.SubjectTasks},
{Subject: model.SubjectCalendar},
{Subject: model.SubjectContacts},
{Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies},
{Subject: model.SubjectOpportunities},
{Subject: model.SubjectCases},
{Subject: model.SubjectCRMTasks},
{Subject: model.SubjectMailInbox},
{Subject: model.SubjectUsers},
}
for _, spec := range cases {
t.Run(string(spec.Subject), func(t *testing.T) {
items, err := loader.List(ctx, spec)
if err != nil {
t.Fatalf("List(%s): %v", spec.Subject, err)
}
t.Logf("%s: %d items", spec.Subject, len(items))
for i, it := range items {
if it.ID == "" {
t.Errorf("item[%d] missing ID", i)
}
if it.Title == "" {
t.Errorf("item[%d] missing Title", i)
}
}
})
}
}
func TestIntegrationListProjectsMapsRealFields(t *testing.T) {
loader, ctx := liveLoader(t)
spec := model.ListSpec{Subject: model.SubjectProjects}
items, err := loader.List(ctx, spec)
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects on instance")
}
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("Detail: %v", err)
}
if detail == nil {
t.Fatal("nil detail")
}
}
func TestIntegrationMailInboxDetail(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("inbox empty")
}
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("GetMailMessage: %v", err)
}
if detail["subject"] == nil {
t.Fatalf("message missing subject: %+v", detail)
}
}
+412
View File
@@ -0,0 +1,412 @@
package fetch
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// MailListPageSize is how many messages each infinite-scroll fetch loads.
const MailListPageSize = 25
// Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct {
Client *onlyoffice.Client
}
// List returns items for the given list spec (nav leaf).
func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
switch spec.Subject {
case model.SubjectProjects:
return l.listProjects(ctx)
case model.SubjectTasks:
if spec.ProjectID != "" {
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx)
case model.SubjectCalendar:
return l.ListCalendar(ctx)
case model.SubjectCalendars:
return l.ListCalendar(ctx)
case model.SubjectEvents:
return l.ListCalendar(ctx)
case model.SubjectContacts:
return l.listContacts(ctx, nil)
case model.SubjectPersons:
falseVal := false
return l.listContacts(ctx, &falseVal)
case model.SubjectCompanies:
trueVal := true
return l.listContacts(ctx, &trueVal)
case model.SubjectOpportunities:
return l.listOpportunities(ctx)
case model.SubjectCases:
return l.listCases(ctx)
case model.SubjectCRMTasks:
return l.listCRMTasks(ctx)
case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
case model.SubjectUsers:
return l.listUsers(ctx)
case model.SubjectProjectFiles:
pid := spec.ProjectID
if pid == "" {
pid = onlyoffice.GetEnvironmentDefaults().ProjectID
}
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
}
return l.listProjectFiles(ctx, pid)
case model.SubjectTaskFiles:
if spec.TaskID == "" {
return nil, fmt.Errorf("pick a task under Projects in the tree")
}
return l.listTaskFiles(ctx, spec.TaskID)
default:
return nil, fmt.Errorf("unsupported subject %q", spec.Subject)
}
}
// LoadProjectsForNav returns projects to inject as dynamic tree nodes.
func (l *Loader) LoadProjectsForNav(ctx context.Context) ([]model.Item, error) {
return l.listProjects(ctx)
}
// Detail fetches full record data for preview when list row is insufficient.
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
switch item.Kind {
case model.KindOpportunity:
return l.Client.GetOpportunity(ctx, item.ID)
case model.KindContact:
return l.Client.GetContact(ctx, item.ID)
case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask, model.KindCRMTask:
return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID)
default:
if item.Raw != nil {
return item.Raw, nil
}
return map[string]any{"id": item.ID, "title": item.Title}, nil
}
}
// Execute runs a user-selected action on an item.
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
switch action {
case model.ActionView:
return "view", nil
case model.ActionDelete:
return l.executeDelete(ctx, item)
case model.ActionDownload:
return l.executeDownload(ctx, item, destPath)
default:
return "", fmt.Errorf("unsupported action %q", action)
}
}
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
switch item.Kind {
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.DeleteProject(id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted project %s", item.Title), nil
case model.KindTask:
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted task %s", item.Title), nil
case model.KindContact:
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted contact %s", item.Title), nil
case model.KindOpportunity:
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted deal %s", item.Title), nil
case model.KindCase:
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted case %s", item.Title), nil
case model.KindCRMTask:
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
case model.KindMail:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted message %s", item.Title), nil
case model.KindFile:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
return "", err
}
return fmt.Sprintf("Deleted file %s", item.Title), nil
default:
return "", fmt.Errorf("delete not supported for %s", item.Kind)
}
}
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
if item.Kind != model.KindFile {
return "", fmt.Errorf("download only for files")
}
if destPath == "" {
destPath = filepath.Join(os.TempDir(), "office", item.Title)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", err
}
f, err := os.Create(destPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
return "", err
}
return fmt.Sprintf("Downloaded to %s", destPath), nil
}
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
rows, err := l.Client.ListTasks(ctx, projectID, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
files, err := l.Client.GetTaskFiles(ctx, taskID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
projects, err := l.Client.GetProjects()
if err != nil {
return nil, err
}
items := make([]model.Item, len(projects))
for i, p := range projects {
id := ""
if p.ID != nil {
id = fmt.Sprint(*p.ID)
}
title := ""
if p.Title != nil {
title = *p.Title
}
raw := map[string]any{
"id": id,
"title": title,
}
if p.TaskCount != nil {
raw["taskCount"] = *p.TaskCount
}
if p.TaskCountTotal != nil {
raw["taskCountTotal"] = *p.TaskCountTotal
}
if p.DocumentsCount != nil {
raw["documentsCount"] = *p.DocumentsCount
}
if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount
}
if p.Status != nil {
raw["status"] = *p.Status
}
items[i] = model.Item{
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
}
}
return items, nil
}
func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllTasks(ctx, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx)
if err != nil {
return nil, err
}
if companyOnly != nil {
filtered := make([]map[string]any, 0, len(rows))
for _, r := range rows {
isCo, _ := r["isCompany"].(bool)
if isCo == *companyOnly {
filtered = append(filtered, r)
}
}
rows = filtered
}
return ItemsFromMaps(rows, model.KindContact, ContactItemFields), nil
}
func (l *Loader) listOpportunities(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListAllOpportunities(ctx)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindOpportunity, FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "stage"}), nil
}
func (l *Loader) listCases(ctx context.Context) ([]model.Item, error) {
cases, _, err := l.Client.ListCases(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(cases, model.KindCase, FieldMap{IDKey: "id", TitleKey: "title"}), nil
}
func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
rows, _, err := l.Client.ListCRMTasks(ctx, 100, 0)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
}
func (l *Loader) listMail(ctx context.Context, folder int, startIndex int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{
Folder: folder,
Count: MailListPageSize,
StartIndex: startIndex,
})
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
}
// ListMailMore loads the next page of messages for an active mail folder list.
func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) {
folder, ok := mailFolderForSubject(spec.Subject)
if !ok {
return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject)
}
return l.listMail(ctx, folder, startIndex)
}
func mailFolderForSubject(subject model.Subject) (int, bool) {
switch subject {
case model.SubjectMailInbox:
return onlyoffice.MailFolderInbox, true
case model.SubjectMailSent:
return onlyoffice.MailFolderSent, true
case model.SubjectMailDrafts:
return onlyoffice.MailFolderDrafts, true
case model.SubjectMailTrash:
return onlyoffice.MailFolderTrash, true
case model.SubjectMailSpam:
return onlyoffice.MailFolderSpam, true
default:
return 0, false
}
}
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
users, err := l.Client.GetUsers()
if err != nil {
return nil, err
}
items := make([]model.Item, len(users))
for i, u := range users {
id := ""
if u.ID != nil {
id = fmt.Sprint(*u.ID)
}
title := ""
if u.DisplayName != nil {
title = *u.DisplayName
}
email := ""
if u.Email != nil {
email = *u.Email
}
items[i] = model.Item{
ID: id, Title: title, Subtitle: email, Kind: model.KindUser,
Raw: map[string]any{"id": id, "displayName": title, "email": email},
}
}
return items, nil
}
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
resp, err := l.Client.GetProjectFiles(ctx, projectID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range resp.Files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
+78
View File
@@ -0,0 +1,78 @@
package fetch
import (
"fmt"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// FieldMap names the JSON keys used when building list items from API rows.
type FieldMap struct {
IDKey string
TitleKey string
SubtitleKey string
}
// TaskItemFields is the default field map for project tasks.
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"}
// ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
// ContactItemFields is the default field map for CRM contacts.
var ContactItemFields = FieldMap{IDKey: "id", TitleKey: "displayName", SubtitleKey: "primaryEmail"}
// MailItemFields is the default field map for mail messages.
var MailItemFields = FieldMap{IDKey: "id", TitleKey: "subject", SubtitleKey: "from"}
// ItemsFromMaps converts OnlyOffice list rows into TUI items.
func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []model.Item {
out := make([]model.Item, len(rows))
for i, row := range rows {
title := str(row, fields.TitleKey)
if title == "" {
title = str(row, "title")
}
if title == "" {
title = str(row, "name")
}
if title == "" {
title = "(untitled)"
}
raw := row
out[i] = model.Item{
ID: idStr(row, fields.IDKey),
Title: title,
Subtitle: str(row, fields.SubtitleKey),
Kind: kind,
Raw: raw,
}
}
return out
}
func idStr(m map[string]any, key string) string {
if key == "" {
key = "id"
}
switch v := m[key].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
case int:
return fmt.Sprintf("%d", v)
default:
return fmt.Sprint(m[key])
}
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
+43
View File
@@ -0,0 +1,43 @@
package fetch
import (
"bytes"
"context"
"fmt"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
// PreviewMarkdown loads item data and returns markdown for the preview pane.
func (l *Loader) PreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if l == nil || l.Client == nil {
return "", fmt.Errorf("fetch: client is nil")
}
if item.Kind == model.KindFile {
return l.filePreviewMarkdown(ctx, item)
}
raw, err := l.Detail(ctx, item)
if err != nil {
return "", err
}
return preview.EntityMarkdown(string(item.Kind), raw), nil
}
func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if item.ID == "" {
return "", fmt.Errorf("file id missing")
}
name := item.Title
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
if t := onlyoffice.FileEntryTitle(meta); t != "" {
name = t
}
}
var buf bytes.Buffer
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
return "", err
}
return preview.FileBytesToMarkdown(name, buf.Bytes())
}
+64
View File
@@ -0,0 +1,64 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// SaveItem persists editable form fields for supported entity kinds.
func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.FormFields) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
switch item.Kind {
case model.KindTask:
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
case model.KindProject:
return l.saveProject(ctx, item.ID, fields)
default:
return fmt.Errorf("save not supported for %s", item.Kind)
}
}
func (l *Loader) saveProject(ctx context.Context, projectID string, fields model.FormFields) error {
id, err := strconv.Atoi(projectID)
if err != nil {
return err
}
if fields.ResponsibleID == "" {
raw, derr := l.Detail(ctx, model.Item{ID: projectID, Kind: model.KindProject})
if derr == nil {
fields.ResponsibleID = model.ResponsibleIDFromRaw(raw)
}
}
req := onlyoffice.ProjectUpdateRequest{
ID: id,
Title: fields.Primary,
Description: fields.Secondary,
}
if fields.ResponsibleID != "" {
req.ResponsibleID = fields.ResponsibleID
}
if _, err := l.Client.UpdateProject(req); err != nil {
return err
}
if fields.HasStatus {
if _, err := l.Client.UpdateProjectStatus(id, string(fields.Status)); err != nil {
return err
}
}
return nil
}
// DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item)
if err != nil {
return model.FormFields{}, err
}
return model.FormFieldsFromRaw(item.Kind, raw), nil
}
+52
View File
@@ -0,0 +1,52 @@
//go:build integration
package fetch_test
import (
"testing"
"time"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationSaveProject(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects")
}
item := items[0]
fields, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatalf("DetailForm: %v", err)
}
if fields.ResponsibleID == "" {
t.Skip("project has no responsible id on this instance")
}
marker := " office-save-test " + time.Now().UTC().Format(time.RFC3339)
fields.Primary = fields.Primary + marker
if err := loader.SaveItem(ctx, item, fields); err != nil {
t.Fatalf("SaveItem: %v", err)
}
after, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatalf("DetailForm after save: %v", err)
}
if after.Primary != fields.Primary {
t.Fatalf("title not saved: got %q want %q", after.Primary, fields.Primary)
}
fields.Primary = stringsTrimSuffixMarker(after.Primary, marker)
if err := loader.SaveItem(ctx, item, fields); err != nil {
t.Logf("cleanup save: %v", err)
}
}
func stringsTrimSuffixMarker(s, marker string) string {
if len(s) >= len(marker) && s[len(s)-len(marker):] == marker {
return s[:len(s)-len(marker)]
}
return s
}
+36
View File
@@ -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
}
+68
View File
@@ -0,0 +1,68 @@
//go:build integration
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
item := items[0]
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
}
+34
View File
@@ -0,0 +1,34 @@
// Command office is a terminal UI for OnlyOffice Workspace.
//
// Three-pane layout: module tree (left), selectable list (center),
// markdown preview (right). Uses github.com/eslider/go-onlyoffice for API calls.
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/office@latest
package main
import (
"context"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func main() {
client, err := bootstrap.NewClient(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := ui.NewModel(client)
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+64
View File
@@ -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
}
}
+40
View File
@@ -0,0 +1,40 @@
package model
import "fmt"
// ClassifyCalendarRow decides whether an API row is a calendar or an event.
func ClassifyCalendarRow(raw map[string]any) (Kind, string) {
if raw == nil {
return KindCalendar, "Calendar"
}
if strRaw(raw, "start") != "" || strRaw(raw, "end") != "" {
return KindEvent, "Event"
}
if strRaw(raw, "eventType") != "" {
return KindEvent, "Event"
}
return KindCalendar, "Calendar"
}
// CalendarTypeLabel returns the type column text for a calendar list row.
func CalendarTypeLabel(it Item) string {
if it.Raw != nil {
if t := strRaw(it.Raw, "type"); t != "" {
return t
}
}
_, label := ClassifyCalendarRow(it.Raw)
return label
}
// FormatCalendarDateTime formats start/end timestamps for the calendar table.
func FormatCalendarDateTime(v any) string {
t, ok := parseDeadlineTime(v)
if !ok {
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
return ""
}
return t.Format("Jan 2 15:04")
}
+37
View File
@@ -0,0 +1,37 @@
package model
import "testing"
func TestClassifyCalendarRow(t *testing.T) {
kind, label := ClassifyCalendarRow(map[string]any{
"title": "Standup",
"start": "2026-06-24T10:00:00Z",
})
if kind != KindEvent || label != "Event" {
t.Fatalf("got kind=%s label=%q", kind, label)
}
kind, label = ClassifyCalendarRow(map[string]any{"title": "Work"})
if kind != KindCalendar || label != "Calendar" {
t.Fatalf("got kind=%s label=%q", kind, label)
}
}
func TestBuildCalendarColumnsIncludesType(t *testing.T) {
items := []Item{{
ID: "1", Title: "Meet", Kind: KindEvent,
Raw: map[string]any{"type": "Event", "start": "2026-06-24T10:00:00Z"},
}}
cols := BuildColumns(SubjectCalendar, items)
found := false
for _, c := range cols {
if c.Key == "type" {
found = true
}
if c.Key == "subtitle" {
t.Fatal("subtitle column should not appear")
}
}
if !found {
t.Fatal("expected type column")
}
}
+367
View File
@@ -0,0 +1,367 @@
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)
}
if subject == SubjectTasks {
return buildTaskColumns(items)
}
if subject == SubjectCalendar {
return buildCalendarColumns(items)
}
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
if hasSubtitle(items) {
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
}
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
for _, key := range subjectExtraKeys[subject] {
if seen[key] || !columnHasData(items, key) {
continue
}
seen[key] = true
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
discovered := discoverRawKeys(items, seen, 8)
for _, key := range discovered {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
sizeColumns(cols, items)
return cols
}
func buildCalendarColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "type", Title: "Type", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
for _, key := range []string{"start", "end"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildTaskColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "status", Title: "Status", Width: 10},
}
for _, key := range []string{"deadline", "responsible"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildProjectColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "Status", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
{Key: "documents", Title: "Documents", Width: 11},
{Key: "users", Title: "Users", Width: 8},
}
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 "type":
return CalendarTypeLabel(it)
case "start", "end":
if it.Raw == nil {
return ""
}
if formatted := FormatCalendarDateTime(it.Raw[key]); formatted != "" {
return formatted
}
return formatAny(it.Raw[key])
case "status":
switch it.Kind {
case KindProject:
return ProjectStatusLabel(it.Raw)
case KindTask, KindCRMTask:
if it.Raw == nil {
return ""
}
return TaskStatusLabel(it.Raw["status"])
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["status"])
}
case "deadline":
if it.Raw == nil {
return ""
}
if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" {
return rel
}
return formatAny(it.Raw["deadline"])
case "responsible":
if it.Kind == KindTask || it.Kind == KindCRMTask {
return TaskResponsibleLabel(it.Raw)
}
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["responsible"])
case "subtitle":
return it.Subtitle
case "tasks":
return formatProjectTasks(it.Raw)
case "documents":
return intRaw(it.Raw, "documentsCount")
case "users":
return intRaw(it.Raw, "participantCount")
case "kind":
return string(it.Kind)
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw[key])
}
}
func formatProjectTasks(raw map[string]any) string {
if raw == nil {
return "0/0"
}
open := intRawVal(raw, "taskCount")
total := intRawVal(raw, "taskCountTotal")
closed := total - open
if closed < 0 {
closed = 0
}
return fmt.Sprintf("%d/%d", open, closed)
}
func intRaw(raw map[string]any, key string) string {
if raw == nil {
return ""
}
v := intRawVal(raw, key)
if v == 0 {
if _, ok := raw[key]; !ok {
return ""
}
}
return fmt.Sprintf("%d", v)
}
func intRawVal(raw map[string]any, key string) int {
if raw == nil {
return 0
}
switch v := raw[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
default:
if raw[key] == nil {
return 0
}
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
return n
}
}
func hasSubtitle(items []Item) bool {
for _, it := range items {
if it.Subtitle != "" {
return true
}
}
return false
}
func columnHasData(items []Item, key string) bool {
for _, it := range items {
if CellText(it, key) != "" {
return true
}
}
return false
}
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
counts := map[string]int{}
for _, it := range items {
if it.Raw == nil {
continue
}
for k := range it.Raw {
if seen[k] || k == "id" || k == "title" {
continue
}
counts[k]++
}
}
type kv struct {
k string
n int
}
var ranked []kv
for k, n := range counts {
ranked = append(ranked, kv{k, n})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].n == ranked[j].n {
return ranked[i].k < ranked[j].k
}
return ranked[i].n > ranked[j].n
})
out := make([]string, 0, limit)
for _, r := range ranked {
if len(out) >= limit {
break
}
out = append(out, r.k)
}
return out
}
func titleLabel(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
func defaultWidth(key string) int {
switch key {
case "id", "status", "stage":
return 10
case "start", "end", "deadline", "date":
return 20
case "from", "to", "email", "primaryEmail":
return 22
case "description", "displayName":
return 24
default:
return 14
}
}
func sizeColumns(cols []TableColumn, items []Item) {
for i := range cols {
maxW := runeLen(cols[i].Title)
for _, it := range items {
n := runeLen(CellText(it, cols[i].Key))
if n > maxW {
maxW = n
}
}
maxW += 2
if maxW < 4 {
maxW = 4
}
if maxW > 36 {
maxW = 36
}
cols[i].Width = maxW
}
}
func runeLen(s string) int {
return len([]rune(s))
}
func formatAny(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case bool:
if x {
return "yes"
}
return "no"
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%g", x)
default:
return fmt.Sprint(v)
}
}
+66
View File
@@ -0,0 +1,66 @@
package model
import "testing"
func TestBuildProjectColumns(t *testing.T) {
items := []Item{{
ID: "1", Title: "Alpha", Kind: KindProject,
Raw: map[string]any{
"taskCount": 3, "taskCountTotal": 5,
"documentsCount": 2, "participantCount": 4,
},
}}
cols := BuildColumns(SubjectProjects, items)
want := []string{"_sel", "id", "status", "title", "tasks", "documents", "users"}
if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", len(cols), len(want))
}
for i, key := range want {
if cols[i].Key != key {
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
}
}
for _, c := range cols {
if c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key)
}
}
}
func TestCellTextProjectStatus(t *testing.T) {
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
if got := CellText(open, "status"); got != "Open" {
t.Fatalf("got %q", got)
}
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
if got := CellText(closed, "status"); got != "Closed" {
t.Fatalf("got %q", got)
}
}
func TestFormatProjectTasksOpenClosed(t *testing.T) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" {
t.Fatalf("got %q want 3/5", got)
}
}
func TestCellTextProjectDocumentsUsers(t *testing.T) {
it := Item{Raw: map[string]any{"documentsCount": 7, "participantCount": 2}}
if got := CellText(it, "documents"); got != "7" {
t.Fatalf("documents: %q", got)
}
if got := CellText(it, "users"); got != "2" {
t.Fatalf("users: %q", got)
}
}
func TestBuildColumnsIncludesSelectionAndCoreFields(t *testing.T) {
items := []Item{
{ID: "1", Title: "Alpha", Kind: KindTask, Raw: map[string]any{"status": "Open"}},
}
cols := BuildColumns(SubjectTasks, items)
if cols[0].Key != "_sel" {
t.Fatalf("first column should be selection, got %q", cols[0].Key)
}
}
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"strings"
)
// FilterItems returns items whose searchable text contains query (case-insensitive).
func FilterItems(items []Item, query string) []Item {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
out := make([]Item, len(items))
copy(out, items)
return out
}
out := make([]Item, 0, len(items))
for _, it := range items {
if strings.Contains(strings.ToLower(ItemSearchText(it)), q) {
out = append(out, it)
}
}
return out
}
// ItemSearchText joins item fields used for list filtering.
func ItemSearchText(it Item) string {
var parts []string
parts = append(parts, it.ID, it.Title, it.Subtitle, string(it.Kind))
if it.Raw != nil {
for _, v := range it.Raw {
parts = append(parts, formatAny(v))
}
}
return strings.Join(parts, " ")
}
+26
View File
@@ -0,0 +1,26 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestFilterItemsByTitle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "Alpha task"},
{ID: "2", Title: "Beta task"},
}
got := model.FilterItems(items, "alpha")
if len(got) != 1 || got[0].ID != "1" {
t.Fatalf("got %#v", got)
}
}
func TestFilterItemsEmptyQueryReturnsAll(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A"}, {ID: "2", Title: "B"}}
got := model.FilterItems(items, " ")
if len(got) != 2 {
t.Fatalf("got len=%d want 2", len(got))
}
}
+145
View File
@@ -0,0 +1,145 @@
package model
import "fmt"
// FormFields holds editable/display fields loaded from API detail.
type FormFields struct {
PrimaryLabel string
SecondaryLabel string
Primary string
Secondary string
ReadOnly bool
HasStatus bool
Status ProjectLifecycle
ResponsibleID string
}
// KindHeading returns a short label for the detail pane header.
func KindHeading(kind Kind, id string) string {
return fmt.Sprintf("%s %s", kindLabel(kind), id)
}
func kindLabel(kind Kind) string {
switch kind {
case KindProject:
return "Project"
case KindTask:
return "Task"
case KindCRMTask:
return "CRM task"
case KindContact:
return "Contact"
case KindOpportunity:
return "Deal"
case KindCase:
return "Case"
case KindMail:
return "Mail"
case KindEvent:
return "Event"
case KindCalendar:
return "Calendar"
case KindFile:
return "Document"
case KindUser:
return "User"
default:
return string(kind)
}
}
// FormFieldsFromRaw maps API detail JSON to form fields for the right pane.
func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
switch kind {
case KindMail:
body := strRaw(raw, "body")
if body == "" {
body = strRaw(raw, "htmlBody")
}
return FormFields{
PrimaryLabel: "Subject", SecondaryLabel: "Body",
Primary: strRaw(raw, "subject"), Secondary: body,
ReadOnly: true,
}
case KindUser:
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,
}
case KindProject:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
return FormFields{
PrimaryLabel: "Title",
SecondaryLabel: "Description",
Primary: title,
Secondary: strRaw(raw, "description"),
ReadOnly: false,
HasStatus: true,
Status: ProjectStatusFromAny(raw["status"]),
ResponsibleID: ResponsibleIDFromRaw(raw),
}
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])
}
// ResponsibleIDFromRaw extracts the project responsible user id from API detail.
func ResponsibleIDFromRaw(raw map[string]any) string {
if id := strRaw(raw, "responsibleId"); id != "" {
return id
}
resp, ok := raw["responsible"].(map[string]any)
if !ok {
return ""
}
return strRaw(resp, "id")
}
// IsDocumentKind is true when the right pane should show rendered preview content, not an editable form.
func IsDocumentKind(kind Kind) bool {
return kind == KindFile || kind == KindMail
}
+23
View File
@@ -0,0 +1,23 @@
package model
import "testing"
func TestFormFieldsFromRawMail(t *testing.T) {
raw := map[string]any{"subject": "Hi", "body": "Hello"}
f := FormFieldsFromRaw(KindMail, raw)
if f.Primary != "Hi" || f.Secondary != "Hello" || !f.ReadOnly {
t.Fatalf("unexpected mail fields: %+v", f)
}
}
func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) {
t.Fatal("file should be document")
}
if !IsDocumentKind(KindMail) {
t.Fatal("mail should be document preview")
}
if IsDocumentKind(KindTask) {
t.Fatal("task should be form")
}
}
+137
View File
@@ -0,0 +1,137 @@
package model
// Kind identifies the OnlyOffice entity type for list items and preview routing.
type Kind string
const (
KindProject Kind = "project"
KindTask Kind = "task"
KindContact Kind = "contact"
KindOpportunity Kind = "opportunity"
KindCase Kind = "case"
KindCRMTask Kind = "crm_task"
KindMail Kind = "mail"
KindEvent Kind = "event"
KindCalendar Kind = "calendar"
KindFile Kind = "file"
KindUser Kind = "user"
)
// Subject identifies a menu leaf / list source.
type Subject string
const (
SubjectProjects Subject = "projects"
SubjectTasks Subject = "tasks"
SubjectCalendars Subject = "calendars"
SubjectEvents Subject = "events"
SubjectContacts Subject = "contacts"
SubjectPersons Subject = "persons"
SubjectCompanies Subject = "companies"
SubjectOpportunities Subject = "opportunities"
SubjectCases Subject = "cases"
SubjectCRMTasks Subject = "crm_tasks"
SubjectMailInbox Subject = "mail_inbox"
SubjectMailSent Subject = "mail_sent"
SubjectMailDrafts Subject = "mail_drafts"
SubjectMailTrash Subject = "mail_trash"
SubjectMailSpam Subject = "mail_spam"
SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users"
SubjectCalendar Subject = "calendar"
)
// SubjectIsMail reports whether a list subject loads mailbox messages.
func SubjectIsMail(s Subject) bool {
switch s {
case SubjectMailInbox, SubjectMailSent, SubjectMailDrafts, SubjectMailTrash, SubjectMailSpam:
return true
default:
return false
}
}
// FocusPane is which column has keyboard focus.
type FocusPane int
const (
FocusMenu FocusPane = iota
FocusList
FocusPreview
)
// PrevFocusPane cycles preview → list → menu → preview.
func PrevFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusPreview
case FocusList:
return FocusMenu
default:
return FocusList
}
}
// NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusList
case FocusList:
return FocusPreview
default:
return FocusMenu
}
}
// Item is one row in the center list pane.
type Item struct {
ID, Title, Subtitle string
Kind Kind
Raw map[string]any
Selected bool
}
// Selection tracks multi-selected item IDs within the current subject.
type Selection struct {
ids map[string]struct{}
}
// NewSelection returns an empty selection set.
func NewSelection() *Selection {
return &Selection{ids: make(map[string]struct{})}
}
// Toggle flips selection on items[idx] and updates the ID set.
func (s *Selection) Toggle(items *[]Item, idx int) {
if idx < 0 || idx >= len(*items) {
return
}
it := &(*items)[idx]
it.Selected = !it.Selected
if it.Selected {
s.ids[it.ID] = struct{}{}
} else {
delete(s.ids, it.ID)
}
}
// Count returns the number of selected items.
func (s *Selection) Count() int {
return len(s.ids)
}
// Clear removes all selections.
func (s *Selection) Clear() {
s.ids = make(map[string]struct{})
}
// IDs returns selected item IDs in stable order (map iteration order is fine for display).
func (s *Selection) IDs() []string {
out := make([]string, 0, len(s.ids))
for id := range s.ids {
out = append(out, id)
}
return out
}
+12
View File
@@ -0,0 +1,12 @@
package model
import "testing"
func TestSubjectIsMail(t *testing.T) {
if !SubjectIsMail(SubjectMailInbox) {
t.Fatal("inbox should be mail")
}
if SubjectIsMail(SubjectProjects) {
t.Fatal("projects should not be mail")
}
}
+45
View File
@@ -0,0 +1,45 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestSelectionToggle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "A"},
{ID: "2", Title: "B"},
}
sel := model.NewSelection()
if sel.Count() != 0 {
t.Fatalf("initial count=%d", sel.Count())
}
sel.Toggle(&items, 0)
if !items[0].Selected || sel.Count() != 1 {
t.Fatalf("after toggle 0: selected=%v count=%d", items[0].Selected, sel.Count())
}
sel.Toggle(&items, 0)
if items[0].Selected || sel.Count() != 0 {
t.Fatalf("after second toggle: selected=%v count=%d", items[0].Selected, sel.Count())
}
}
func TestSelectionClearOnSubjectChange(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A", Selected: true}}
sel := model.NewSelection()
sel.Toggle(&items, 0)
sel.Clear()
if sel.Count() != 0 {
t.Fatalf("count=%d after clear", sel.Count())
}
}
func TestNextFocusPane(t *testing.T) {
if got := model.NextFocusPane(model.FocusMenu); got != model.FocusList {
t.Fatalf("menu->list got %v", got)
}
if got := model.NextFocusPane(model.FocusPreview); got != model.FocusMenu {
t.Fatalf("preview->menu got %v", got)
}
}
+254
View File
@@ -0,0 +1,254 @@
package model
import "strings"
// ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct {
Subject Subject
ProjectID string
TaskID string
}
// NavNode is one row in the navigation tree.
type NavNode struct {
ID string
Label string
Branch bool
List *ListSpec // set on leaves that open the center list
ParentID string
}
// NavTree is a hierarchical navigator; the center list loads only on leaves.
type NavTree struct {
nodes map[string]NavNode
roots []string
children map[string][]string
expanded map[string]bool
cursor int
visible []string
filter string
}
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
func DefaultNavTree() *NavTree {
t := &NavTree{
nodes: make(map[string]NavNode),
children: make(map[string][]string),
expanded: make(map[string]bool),
}
add := func(id, label, parent string, branch bool, list *ListSpec) {
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
if parent == "" {
t.roots = append(t.roots, id)
} else {
t.children[parent] = append(t.children[parent], id)
}
}
add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "", true, nil)
add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
add("mail", "Mail", "", true, nil)
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", 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) SetFilter(query string) {
t.filter = strings.ToLower(strings.TrimSpace(query))
t.rebuildVisible()
}
func (t *NavTree) ClearFilter() {
t.filter = ""
t.rebuildVisible()
}
func (t *NavTree) FilterQuery() string { return t.filter }
func (t *NavTree) nodeLabelMatches(id string) bool {
if t.filter == "" {
return true
}
n, ok := t.nodes[id]
if !ok {
return false
}
return strings.Contains(strings.ToLower(n.Label), t.filter)
}
func (t *NavTree) hasMatchingDescendant(id string) bool {
for _, child := range t.children[id] {
if t.nodeLabelMatches(child) || t.hasMatchingDescendant(child) {
return true
}
}
return false
}
func (t *NavTree) visibleUnderFilter(id string) bool {
if t.filter == "" {
return true
}
return t.nodeLabelMatches(id) || t.hasMatchingDescendant(id)
}
func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0]
var walk func(id string)
walk = func(id string) {
if !t.visibleUnderFilter(id) {
return
}
t.visible = append(t.visible, id)
expanded := t.expanded[id]
if t.filter != "" && t.hasMatchingDescendant(id) {
expanded = true
}
if !expanded {
return
}
for _, child := range t.children[id] {
walk(child)
}
}
for _, root := range t.roots {
walk(root)
}
if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1
}
if t.cursor < 0 {
t.cursor = 0
}
}
+31
View File
@@ -0,0 +1,31 @@
package model
import "strings"
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
func (t *NavTree) InjectProjectNodes(projects []Item) {
// Drop prior dynamic project.* nodes.
for id := range t.nodes {
if strings.HasPrefix(id, "project.") {
delete(t.nodes, id)
}
}
t.children["projects.dynamic"] = nil
for _, p := range projects {
pid := "project." + p.ID
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
taskID := pid + ".tasks"
fileID := pid + ".files"
t.nodes[taskID] = NavNode{
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
}
t.nodes[fileID] = NavNode{
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
}
t.children[pid] = []string{taskID, fileID}
}
t.rebuildVisible()
}
+147
View File
@@ -0,0 +1,147 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree()
roots := tree.RootLabels()
want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree()
var crm int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "CRM" {
crm = i
break
}
}
if crm < 0 {
t.Fatal("CRM node not found")
}
if !tree.IsExpandable(crm) {
t.Fatal("CRM should be expandable")
}
tree.ToggleExpand(crm)
if !tree.IsExpanded(crm) {
t.Fatal("CRM should expand")
}
tree.ToggleExpand(crm)
if tree.IsExpanded(crm) {
t.Fatal("CRM should collapse")
}
}
func TestNavCalendarLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Calendar" {
found = i
break
}
}
if found < 0 {
t.Fatal("Calendar node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectCalendar {
t.Fatalf("spec=%v ok=%v", spec, ok)
}
}
func TestNavProjectsLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetCursor(0)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("projects spec=%v ok=%v", spec, ok)
}
}
func TestNavTasksLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Tasks" {
found = i
break
}
}
if found < 0 {
t.Fatal("Tasks node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectTasks {
t.Fatalf("tasks spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
}
func TestNavFilterByLabel(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetFilter("inbox")
found := false
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Inbox" {
found = true
break
}
}
if !found {
t.Fatal("Inbox should remain visible when filtering mail")
}
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Calendar" {
t.Fatal("Calendar should be hidden when filtering inbox")
}
}
}
func TestNavClearFilterRestoresTree(t *testing.T) {
tree := model.DefaultNavTree()
before := tree.VisibleCount()
tree.SetFilter("zzz-no-match")
if tree.VisibleCount() >= before {
t.Fatalf("filter should shrink visible nodes")
}
tree.ClearFilter()
if tree.VisibleCount() != before {
t.Fatalf("clear filter should restore visible count")
}
}
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
t.Fatalf("expected delete only, got %v", acts)
}
}
+85
View File
@@ -0,0 +1,85 @@
package model
// ProjectLifecycle is the OnlyOffice project status string accepted by the API.
type ProjectLifecycle string
const (
ProjectLifecycleOpen ProjectLifecycle = "open"
ProjectLifecyclePaused ProjectLifecycle = "paused"
ProjectLifecycleClosed ProjectLifecycle = "closed"
)
// ProjectStatusFromAny maps API status (int or string) to a lifecycle value.
func ProjectStatusFromAny(v any) ProjectLifecycle {
switch x := v.(type) {
case string:
switch ProjectLifecycle(x) {
case ProjectLifecycleOpen, ProjectLifecyclePaused, ProjectLifecycleClosed:
return ProjectLifecycle(x)
}
case float64:
return projectStatusFromInt(int(x))
case int:
return projectStatusFromInt(x)
case int64:
return projectStatusFromInt(int(x))
}
return ProjectLifecycleOpen
}
func projectStatusFromInt(n int) ProjectLifecycle {
switch n {
case 1:
return ProjectLifecyclePaused
case 2:
return ProjectLifecycleClosed
default:
return ProjectLifecycleOpen
}
}
// ProjectStatusLabel returns a short UI label for list cells.
func ProjectStatusLabel(raw map[string]any) string {
if raw == nil {
return "Open"
}
return ProjectStatusFromAny(raw["status"]).Label()
}
// ProjectIsOpen is true when the project is not closed.
func ProjectIsOpen(raw map[string]any) bool {
if raw == nil {
return true
}
return ProjectStatusFromAny(raw["status"]) != ProjectLifecycleClosed
}
// Label returns a human-readable status name.
func (s ProjectLifecycle) Label() string {
switch s {
case ProjectLifecycleClosed:
return "Closed"
case ProjectLifecyclePaused:
return "Paused"
default:
return "Open"
}
}
// ToggleOpenClosed flips between open and closed (paused maps to closed on toggle).
func (s ProjectLifecycle) ToggleOpenClosed() ProjectLifecycle {
if s == ProjectLifecycleClosed {
return ProjectLifecycleOpen
}
return ProjectLifecycleClosed
}
// Next cycles open → closed → open for keyboard toggling.
func (s ProjectLifecycle) Next() ProjectLifecycle {
return s.ToggleOpenClosed()
}
// Prev cycles closed → open → closed.
func (s ProjectLifecycle) Prev() ProjectLifecycle {
return s.ToggleOpenClosed()
}
+26
View File
@@ -0,0 +1,26 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestProjectStatusFromInt(t *testing.T) {
if got := model.ProjectStatusFromAny(2); got != model.ProjectLifecycleClosed {
t.Fatalf("got %q", got)
}
if got := model.ProjectStatusFromAny(0); got != model.ProjectLifecycleOpen {
t.Fatalf("got %q", got)
}
}
func TestProjectStatusToggle(t *testing.T) {
s := model.ProjectLifecycleOpen
if s.Next() != model.ProjectLifecycleClosed {
t.Fatal("expected closed")
}
if s.Next().Prev() != model.ProjectLifecycleOpen {
t.Fatal("expected open after toggle back")
}
}
+185
View File
@@ -0,0 +1,185 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
)
// TaskStatusLabel returns a human-readable project/CRM task status.
func TaskStatusLabel(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
if label := taskStatusFromString(s); label != "" {
return label
}
}
n := intRawVal(map[string]any{"status": v}, "status")
switch n {
case 0:
return "Not accepted"
case 1:
return "Open"
case 2:
return "Closed"
case 3:
return "Disabled"
case 4:
return "Unclassified"
case 5:
return "Not in milestone"
default:
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
return ""
}
}
func taskStatusFromString(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "open":
return "Open"
case "closed":
return "Closed"
case "notaccept", "not accept", "not accepted":
return "Not accepted"
case "disable", "disabled":
return "Disabled"
case "unclassified":
return "Unclassified"
case "notinmilestone", "not in milestone":
return "Not in milestone"
default:
return ""
}
}
// FormatRelativeDeadline formats a deadline as "in 2 hours" or "3 days ago".
func FormatRelativeDeadline(v any) string {
return FormatRelativeDeadlineAt(v, time.Now())
}
// FormatRelativeDeadlineAt is FormatRelativeDeadline with a fixed clock (for tests).
func FormatRelativeDeadlineAt(v any, now time.Time) string {
t, ok := parseDeadlineTime(v)
if !ok {
return ""
}
return relativeTimePhrase(now, t)
}
func relativeTimePhrase(now, target time.Time) string {
d := target.Sub(now)
future := d >= 0
if !future {
d = -d
}
phrase := func(n int, unit string) string {
if n == 1 {
unit = strings.TrimSuffix(unit, "s")
}
if future {
return fmt.Sprintf("in %d %s", n, unit)
}
return fmt.Sprintf("%d %s ago", n, unit)
}
switch {
case d < time.Minute:
if future {
return "in 1 minute"
}
return "just now"
case d < time.Hour:
return phrase(int(d.Round(time.Minute)/time.Minute), "minutes")
case d < 24*time.Hour:
return phrase(int(d.Round(time.Hour)/time.Hour), "hours")
default:
return phrase(int(d.Round(24*time.Hour)/(24*time.Hour)), "days")
}
}
func parseDeadlineTime(v any) (time.Time, bool) {
if v == nil {
return time.Time{}, false
}
switch x := v.(type) {
case time.Time:
return x, true
case string:
s := strings.TrimSpace(x)
if s == "" {
return time.Time{}, false
}
formats := []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.0000000-07:00",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range formats {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
case float64:
return time.Unix(int64(x), 0), true
case int64:
return time.Unix(x, 0), true
case int:
return time.Unix(int64(x), 0), true
default:
return parseDeadlineTime(fmt.Sprint(v))
}
}
// TaskResponsibleLabel returns the assignee display name when present.
func TaskResponsibleLabel(raw map[string]any) string {
if raw == nil {
return ""
}
if name := personName(raw["responsible"]); name != "" {
return name
}
if list, ok := raw["responsibles"].([]any); ok {
names := make([]string, 0, len(list))
for _, entry := range list {
if name := personName(entry); name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
if s := strRaw(raw, "responsible"); s != "" && !looksLikeID(s) {
return s
}
return ""
}
func personName(v any) string {
m, ok := v.(map[string]any)
if !ok {
return ""
}
if name := strRaw(m, "displayName"); name != "" {
return name
}
first := strRaw(m, "firstName")
last := strRaw(m, "lastName")
return strings.TrimSpace(first + " " + last)
}
func looksLikeID(s string) bool {
if len(s) == 36 && strings.Count(s, "-") == 4 {
return true
}
_, err := strconv.Atoi(s)
return err == nil
}
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"testing"
"time"
)
func TestTaskStatusLabelFromInt(t *testing.T) {
if got := TaskStatusLabel(1); got != "Open" {
t.Fatalf("got %q", got)
}
if got := TaskStatusLabel(2); got != "Closed" {
t.Fatalf("got %q", got)
}
}
func TestFormatRelativeDeadlineFuture(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
deadline := now.Add(2 * time.Hour)
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
if got != "in 2 hours" {
t.Fatalf("got %q", got)
}
}
func TestFormatRelativeDeadlineDays(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
deadline := now.Add(26 * time.Hour)
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
if got != "in 1 day" {
t.Fatalf("got %q", got)
}
}
func TestBuildTaskColumnsOmitsSubtitle(t *testing.T) {
items := []Item{{
ID: "1", Title: "Fix bug", Kind: KindTask, Subtitle: "1",
Raw: map[string]any{"status": 1, "deadline": "2026-06-25T12:00:00Z"},
}}
cols := BuildColumns(SubjectTasks, items)
for _, c := range cols {
if c.Key == "subtitle" {
t.Fatalf("unexpected subtitle column")
}
}
if CellText(items[0], "status") != "Open" {
t.Fatalf("status not humanized")
}
}
+10
View File
@@ -0,0 +1,10 @@
package preview
import (
md "github.com/JohannesKaufmann/html-to-markdown/v2"
)
// HTMLToMarkdown converts Document Server HTML output to markdown for TUI preview.
func HTMLToMarkdown(html string) (string, error) {
return md.ConvertString(html)
}
+19
View File
@@ -0,0 +1,19 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestHTMLToMarkdown(t *testing.T) {
html := `<h1>Title</h1><p>Body text</p>`
md, err := preview.HTMLToMarkdown(html)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "Title") || !strings.Contains(md, "Body text") {
t.Fatalf("conversion failed: %q", md)
}
}
+214
View File
@@ -0,0 +1,214 @@
package preview
import (
"fmt"
"html"
"regexp"
"strings"
)
var htmlTagRe = regexp.MustCompile(`<[^>]+>`)
// ContactMarkdown formats a CRM contact or company for preview.
func ContactMarkdown(m map[string]any) string {
name := str(m, "displayName")
if name == "" {
name = strings.TrimSpace(str(m, "firstName") + " " + str(m, "lastName"))
}
kind := "Person"
if boolVal(m, "isCompany") {
kind = "Company"
}
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", name)
fmt.Fprintf(&b, "**Type:** %s\n\n", kind)
if about := str(m, "about"); about != "" {
fmt.Fprintf(&b, "## About\n\n%s\n\n", about)
}
appendInfoList(&b, m)
return strings.TrimSpace(b.String()) + "\n"
}
// OpportunityMarkdown formats a CRM deal for preview.
func OpportunityMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if v := m["bidValue"]; v != nil {
fmt.Fprintf(&b, "**Value:** %v %s\n\n", v, str(m, "bidCurrency"))
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown).
func MailMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
if to := mailAddressLine(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to)
}
if cc := mailAddressLine(m, "cc"); cc != "" {
fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
}
if date := str(m, "date"); date != "" {
fmt.Fprintf(&b, "**Date:** %s\n\n", date)
}
if body := mailBodyMarkdown(m); body != "" {
fmt.Fprintf(&b, "---\n\n%s\n", body)
}
return strings.TrimSpace(b.String()) + "\n"
}
func mailAddressLine(m map[string]any, key string) string {
if v := str(m, key); v != "" {
return v
}
name := str(m, key+"Name")
addr := str(m, key+"Address")
switch {
case name != "" && addr != "":
return fmt.Sprintf("%s <%s>", name, addr)
case addr != "":
return addr
default:
return name
}
}
func mailBodyMarkdown(m map[string]any) string {
htmlBody := strings.TrimSpace(str(m, "htmlBody"))
plainBody := strings.TrimSpace(str(m, "body"))
for _, candidate := range []string{htmlBody, plainBody} {
if candidate == "" {
continue
}
if looksLikeHTML(candidate) {
if md, err := HTMLToMarkdown(candidate); err == nil {
if md = strings.TrimSpace(md); md != "" {
return md
}
}
}
}
if plainBody != "" {
return plainBody
}
if htmlBody != "" {
return stripHTML(htmlBody)
}
return ""
}
func looksLikeHTML(s string) bool {
return strings.Contains(s, "<") && strings.Contains(s, ">") && htmlTagRe.MatchString(s)
}
// EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
fmt.Fprintf(&b, "**Start:** %s\n\n", str(m, "start"))
fmt.Fprintf(&b, "**End:** %s\n\n", str(m, "end"))
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// TaskMarkdown formats a project or CRM task for preview.
func TaskMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "title"))
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "deadline"); d != "" {
fmt.Fprintf(&b, "**Deadline:** %s\n\n", d)
}
if desc := str(m, "description"); desc != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", desc)
}
return strings.TrimSpace(b.String()) + "\n"
}
// ProjectMarkdown formats a project for preview.
func ProjectMarkdown(m map[string]any) string {
var b strings.Builder
title := str(m, "title")
if title == "" {
title = str(m, "name")
}
fmt.Fprintf(&b, "# %s\n\n", title)
if s := str(m, "status"); s != "" {
fmt.Fprintf(&b, "**Status:** %s\n\n", s)
}
if d := str(m, "description"); d != "" {
fmt.Fprintf(&b, "## Description\n\n%s\n\n", d)
}
return strings.TrimSpace(b.String()) + "\n"
}
// EntityMarkdown picks a formatter based on item kind.
func EntityMarkdown(kind string, m map[string]any) string {
switch kind {
case "contact", "person", "company":
return ContactMarkdown(m)
case "opportunity":
return OpportunityMarkdown(m)
case "mail":
return MailMarkdown(m)
case "event":
return EventMarkdown(m)
case "task", "crm_task":
return TaskMarkdown(m)
case "project":
return ProjectMarkdown(m)
default:
return mapToMarkdown(m)
}
}
func mapToMarkdown(m map[string]any) string {
var b strings.Builder
b.WriteString("# Details\n\n")
for k, v := range m {
fmt.Fprintf(&b, "- **%s:** %v\n", k, v)
}
return b.String()
}
func appendInfoList(b *strings.Builder, m map[string]any) {
if infos, ok := m["contactInfos"].([]any); ok && len(infos) > 0 {
b.WriteString("## Contact info\n\n")
for _, raw := range infos {
if row, ok := raw.(map[string]any); ok {
fmt.Fprintf(b, "- %s: %s\n", str(row, "infoType"), str(row, "data"))
}
}
b.WriteString("\n")
}
}
func stripHTML(s string) string {
s = htmlTagRe.ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func str(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
func boolVal(m map[string]any, key string) bool {
v, ok := m[key].(bool)
return ok && v
}
+58
View File
@@ -0,0 +1,58 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestMailMarkdownConvertsHTMLBody(t *testing.T) {
raw := map[string]any{
"subject": "Weekly update",
"from": `"Team" <team@example.com>`,
"htmlBody": `<p>Hello <strong>world</strong></p><p><a href="https://example.com">Link</a></p>`,
}
md := preview.MailMarkdown(raw)
if !strings.Contains(md, "Weekly update") {
t.Fatalf("missing subject:\n%s", md)
}
if !strings.Contains(md, "team@example.com") {
t.Fatalf("missing from:\n%s", md)
}
if strings.Contains(md, "<strong>") || strings.Contains(md, "<p>") {
t.Fatalf("expected HTML converted, got raw tags:\n%s", md)
}
if !strings.Contains(md, "world") {
t.Fatalf("missing body text:\n%s", md)
}
}
func TestMailMarkdownRendersWithColor(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Styled",
"from": "a@b.com",
"htmlBody": `<h2>Heading</h2><p>Plain text</p>`,
})
out, err := preview.RenderMarkdown(md, 60)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) == "" {
t.Fatal("empty render output")
}
if !strings.Contains(out, "Heading") || !strings.Contains(out, "Plain text") {
t.Fatalf("missing rendered content:\n%s", out)
}
}
func TestMailMarkdownPlainBodyFallback(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Hi",
"from": "a@b.com",
"body": "Just plain text",
})
if !strings.Contains(md, "Just plain text") {
t.Fatalf("missing plain body:\n%s", md)
}
}
@@ -0,0 +1,214 @@
//go:build integration
package preview_test
import (
"context"
"os"
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func skipWithoutLiveAPI(t *testing.T) {
t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
}
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
t.Helper()
skipWithoutLiveAPI(t)
client, err := bootstrap.NewClient(context.Background())
if err != nil {
t.Fatal(err)
}
return &fetch.Loader{Client: client}, context.Background()
}
func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.EntityMarkdown(string(model.KindProject), raw)
if md == "" {
t.Fatal("empty markdown")
}
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing project title %q:\n%s", items[0].Title, md)
}
rendered, err := preview.RenderMarkdown(md, 80)
if err != nil {
t.Fatal(err)
}
if rendered == "" {
t.Fatal("glamour produced empty output")
}
}
func TestIntegrationPreviewContactFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no contacts")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.ContactMarkdown(raw)
if md == "" || !strings.Contains(md, "#") {
t.Fatalf("unexpected contact markdown: %q", md)
}
}
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no opportunities")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.OpportunityMarkdown(raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing deal title:\n%s", md)
}
}
func TestIntegrationPreviewMailFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("inbox empty")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.MailMarkdown(raw)
if md == "" || !strings.Contains(md, "From:") {
t.Fatalf("unexpected mail markdown:\n%s", md)
}
}
func TestIntegrationPreviewEventFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no events in range")
}
md := preview.EventMarkdown(items[0].Raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing event title:\n%s", md)
}
}
func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no tasks")
}
raw, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatal(err)
}
md := preview.TaskMarkdown(raw)
if !strings.Contains(md, items[0].Title) {
t.Fatalf("markdown missing task title:\n%s", md)
}
}
func TestIntegrationPreviewFileFromAPI(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no project files")
}
md, err := loader.PreviewMarkdown(ctx, items[0])
if err != nil {
t.Fatalf("PreviewMarkdown: %v", err)
}
if md == "" {
t.Fatal("empty preview")
}
if !strings.Contains(md, items[0].Title) && !strings.Contains(md, "|") && !strings.Contains(md, "```") {
t.Fatalf("unexpected file preview for %q:\n%s", items[0].Title, md)
}
}
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
skipWithoutLiveAPI(t)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not set")
}
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil {
t.Fatal(err)
}
var csvItem *model.Item
for i := range items {
if strings.HasSuffix(strings.ToLower(items[i].Title), ".csv") {
csvItem = &items[i]
break
}
}
if csvItem == nil {
t.Skip("no csv file in project documents")
}
var buf strings.Builder
n, err := loader.Client.DownloadFile(ctx, csvItem.ID, &buf)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if n == 0 {
t.Fatal("empty download")
}
md, err := preview.CSVToMarkdownTable([]byte(buf.String()))
if err != nil {
t.Fatalf("CSVToMarkdownTable: %v", err)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
+23
View File
@@ -0,0 +1,23 @@
package preview
import (
"github.com/charmbracelet/glamour"
)
// RenderMarkdown renders GitHub-flavoured markdown for terminal display.
func RenderMarkdown(md string, width int) (string, error) {
if md == "" {
return "", nil
}
if width < 20 {
width = 20
}
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(width),
)
if err != nil {
return "", err
}
return r.Render(md)
}
+31
View File
@@ -0,0 +1,31 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestRenderMarkdown(t *testing.T) {
out, err := preview.RenderMarkdown("# Hello\n\nWorld", 40)
if err != nil {
t.Fatal(err)
}
if out == "" {
t.Fatal("empty render output")
}
if !strings.Contains(strings.ToLower(out), "hello") {
t.Fatalf("expected rendered heading: %q", out)
}
}
func TestRenderMarkdownEmpty(t *testing.T) {
out, err := preview.RenderMarkdown("", 40)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("expected empty, got %q", out)
}
}
+65
View File
@@ -0,0 +1,65 @@
package preview
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"strings"
xls "github.com/eslider/go-xls/v2"
)
// CSVToMarkdownTable parses CSV bytes into a GitHub pipe table markdown string.
func CSVToMarkdownTable(data []byte) (string, error) {
tab, err := parseCSV(data)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := xls.WriteMarkdownTable(&buf, tab); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()) + "\n", nil
}
func parseCSV(data []byte) (xls.Table, error) {
r := csv.NewReader(bytes.NewReader(data))
r.TrimLeadingSpace = true
records, err := r.ReadAll()
if err != nil {
return xls.Table{}, err
}
if len(records) == 0 {
return xls.Table{}, fmt.Errorf("preview: empty csv")
}
return xls.Table{Columns: records[0], Rows: records[1:]}, nil
}
// JSONToMarkdown wraps JSON in a fenced code block for preview.
func JSONToMarkdown(data []byte) (string, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return "", err
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("```json\n%s\n```\n", string(pretty)), nil
}
// FileBytesToMarkdown picks a preview format based on file extension.
func FileBytesToMarkdown(name string, data []byte) (string, error) {
ext := strings.ToLower(name)
switch {
case strings.HasSuffix(ext, ".csv"):
return CSVToMarkdownTable(data)
case strings.HasSuffix(ext, ".json"):
return JSONToMarkdown(data)
case strings.HasSuffix(ext, ".md"), strings.HasSuffix(ext, ".txt"):
return string(data), nil
default:
return fmt.Sprintf("```\n(binary or unsupported preview for %s, %d bytes)\n```\n", name, len(data)), nil
}
}
+32
View File
@@ -0,0 +1,32 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestCSVToMarkdownTable(t *testing.T) {
csv := "name,score\nAlice,10\nBob,20\n"
md, err := preview.CSVToMarkdownTable([]byte(csv))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "name") || !strings.Contains(md, "Alice") {
t.Fatalf("table missing data: %q", md)
}
if !strings.Contains(md, "|") {
t.Fatalf("expected pipe table: %q", md)
}
}
func TestJSONToMarkdown(t *testing.T) {
md, err := preview.JSONToMarkdown([]byte(`{"a":1}`))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(md, "```") || !strings.Contains(md, `"a"`) {
t.Fatalf("expected fenced json: %q", md)
}
}
+903
View File
@@ -0,0 +1,903 @@
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 listMoreLoadedMsg struct {
items []model.Item
err error
}
type detailLoadedMsg struct {
item model.Item
document bool
fields model.FormFields
markdown string
err error
}
type detailSavedMsg struct {
item model.Item
fields model.FormFields
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
listHasMore bool
listLoadingMore bool
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
filterActive bool
filterSearch FilterSearch
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
}
// 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 · f: filter · 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.filterSearch = newFilterSearch()
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(), filterSearchBlinkCmd())
}
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
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports()
m.syncAllContent()
return m, nil
case tea.KeyMsg:
key := msg.String()
if key == "esc" && m.filterActive {
m.exitFilter()
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
switch key {
case "tab", "shift+tab", "backtab":
// allow pane switching while filter stays active
default:
cmd := m.filterSearch.Update(msg)
m.applyFilter()
return m, cmd
}
}
if key == "?" {
m.status = helpText()
return m, nil
}
if m.scrollFocusedPane(key) {
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview {
// filter input owns keys except pane switching handled above
} else if m.focus == model.FocusPreview {
if 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:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
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.MoveTabStop(-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.MoveTabStop(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 {
if it, ok := m.listTableItem(); ok {
for i := range m.items {
if m.items[i].ID == it.ID {
m.selection.Toggle(&m.items, i)
break
}
}
m.syncListTable()
}
}
return m, nil
case ActionFocusDetail:
if m.hasList && m.showDetail {
m.focus = model.FocusPreview
m.syncPaneFocus()
m.detail.FocusFirstStop()
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 ActionFilter:
m.enterFilter()
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
m.listLoadingMore = false
if msg.err != nil {
m.err = msg.err.Error()
m.items = nil
m.hasList = false
m.listHasMore = false
m.listTable.Clear()
m.detail.Clear()
} else {
m.err = ""
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items)
if m.filterActive {
m.applyFilter()
}
}
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
case listMoreLoadedMsg:
m.listLoadingMore = false
m.listTable.SetLoadingMore(false)
if msg.err != nil {
m.err = msg.err.Error()
return m, nil
}
if len(msg.items) == 0 {
m.listHasMore = false
return m, nil
}
added := m.listTable.AppendItems(msg.items)
if added > 0 {
m.items = m.listTable.Items()
}
m.listHasMore = len(msg.items) >= fetch.MailListPageSize
m.err = ""
return m, nil
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.fields)
m.detail.LoadForm(msg.item, msg.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:
if m.handlePaneResizeMouse(msg) {
return m, nil
}
if cmd, handled := m.routeDetailDocumentMouse(msg); handled {
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:
if !m.filterActive {
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:
if !m.filterActive {
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":
reverse := key != "tab"
if reverse {
if m.detail.TabBackward() {
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
}
} else if m.detail.TabForward() {
m.focus = NextVisibleFocus(m.focus, m.paneVis())
}
m.syncPaneFocus()
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) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if m.filterActive || !m.showDetail {
return nil, false
}
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
return nil, false
}
if msg.Y > m.paneHeight()+1 {
return nil, false
}
x0, x1 := DetailPaneXRange(m.paneLayout())
if x0 >= x1 || msg.X < x0 || msg.X >= x1 {
return nil, false
}
cmd := m.detail.ScrollDocumentMouse(msg)
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
return cmd, true
}
return cmd, false
}
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) {
key := "down"
if delta < 0 {
key = "up"
}
m.detail.ScrollDocument(key)
return m, nil
}
m.detail.MoveTabStop(delta)
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)
menuBody := ApplyVerticalScrollbar(
m.menuVP.View(),
m.menuVP.Width,
m.menuVP.Height,
m.menuVP.TotalLineCount(),
m.menuVP.YOffset,
)
parts = append(parts, menuStyle.Render(menuBody))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
parts = append(parts, listStyle.Render(m.listTable.View()))
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
var detailBody string
if m.filterActive {
detailBody = m.filterSearch.View()
} else {
detailBody = m.detail.View()
}
parts = append(parts, prevStyle.Render(detailBody))
}
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 {
vis := m.paneVis()
if m.customPaneLayout {
return FitPaneWidths(m.width, vis, m.paneSizes)
}
return LayoutWidths(m.width, vis)
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
if m.filterActive {
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
m.detail.SetFocused(false)
return
}
m.filterSearch.SetFocused(false)
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())
}
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
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.listHasMore = false
m.listLoadingMore = 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)
m.filterSearch.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
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
}
m.listTable.UpdateItems(items)
}
func (m *Model) enterFilter() {
if !m.showDetail {
m.showDetail = true
m.layoutViewports()
}
m.filterActive = true
m.focus = model.FocusPreview
m.syncPaneFocus()
m.applyFilter()
}
func (m *Model) exitFilter() {
m.filterActive = false
m.filterSearch.Clear()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
m.syncPaneFocus()
}
func (m *Model) applyFilter() {
q := m.filterSearch.Query()
m.nav.SetFilter(q)
m.syncMenuContent()
m.syncListTable()
}
func (m Model) listTableItem() (model.Item, bool) {
idx := m.listTable.ItemIndex()
if idx < 0 {
return model.Item{}, false
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
}
if idx >= len(items) {
return model.Item{}, false
}
return items[idx], true
}
func (m *Model) syncMenuContent() {
var b strings.Builder
title := "Navigation"
if m.filterActive && m.filterSearch.Query() != "" {
title += " (filtered)"
}
b.WriteString(title + "\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.filterActive {
return nil
}
if !m.hasList {
m.detail.Clear()
return m.maybeLoadMoreList()
}
it, ok := m.listTableItem()
if !ok {
m.detail.Clear()
return m.maybeLoadMoreList()
}
var cmds []tea.Cmd
cmds = append(cmds, m.maybeLoadMoreList())
if m.detail.LoadedID() != it.ID {
m.loading = true
cmds = append(cmds, m.loadDetailCmd(it))
}
return tea.Batch(cmds...)
}
const mailListLoadThreshold = 3
func (m *Model) maybeLoadMoreList() tea.Cmd {
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
return nil
}
if !model.SubjectIsMail(m.listSpec.Subject) {
return nil
}
if !m.listTable.NearEnd(mailListLoadThreshold) {
return nil
}
m.listLoadingMore = true
m.listTable.SetLoadingMore(true)
return m.loadListMoreCmd()
}
func (m *Model) loadListMoreCmd() tea.Cmd {
start := len(m.items)
spec := m.listSpec
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.ListMailMore(ctx, spec, start)
return listMoreLoadedMsg{items: items, err: err}
}
}
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()
fields := m.detail.form.FormFields()
return func() tea.Msg {
ctx := context.Background()
err := m.loader.SaveItem(ctx, item, fields)
return detailSavedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
for i := range m.items {
if m.items[i].ID != item.ID {
continue
}
m.items[i].Title = fields.Primary
if m.items[i].Raw == nil {
m.items[i].Raw = map[string]any{}
}
m.items[i].Raw["title"] = fields.Primary
m.items[i].Raw["description"] = fields.Secondary
if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
m.syncListTable()
return
}
}
func statusInt(s model.ProjectLifecycle) int {
switch s {
case model.ProjectLifecycleClosed:
return 2
case model.ProjectLifecyclePaused:
return 1
default:
return 0
}
}
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 · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
}
+45
View File
@@ -0,0 +1,45 @@
package ui_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestKeyActionMenuDown(t *testing.T) {
if got := ui.KeyAction("j", model.FocusMenu); got != ui.ActionMoveDown {
t.Fatalf("got %v", got)
}
}
func TestKeyActionShiftTabPrevPane(t *testing.T) {
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
t.Fatalf("got %v", got)
}
}
func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
t.Fatalf("tab on preview should stay in pane, got %v", got)
}
}
func TestKeyActionSortAndColumns(t *testing.T) {
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
t.Fatalf("got %v", got)
}
}
func TestKeyActionFilter(t *testing.T) {
if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
}
}
+503
View File
@@ -0,0 +1,503 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
const detailContentRatio = 0.72
type detailMode int
const (
detailEmpty detailMode = iota
detailDocument
detailForm
)
type detailZone int
const (
detailZoneContent detailZone = iota
detailZoneActions
)
// DetailPane is the right column: content top, CRUD actions bottom.
type DetailPane struct {
mode detailMode
item model.Item
loadedID string
actions []model.ItemAction
actionIdx int
tabStop int
zone detailZone
focused bool
width int
height int
form EntityForm
docVP viewport.Model
formVP viewport.Model
docText string
styles detailStyles
}
type detailStyles struct {
sep lipgloss.Style
action lipgloss.Style
actionOn lipgloss.Style
actionDn lipgloss.Style
empty lipgloss.Style
}
func newDetailPane() DetailPane {
d := DetailPane{
form: newEntityForm(),
docVP: viewport.New(40, 10),
formVP: viewport.New(40, 10),
styles: newDetailStyles(),
}
d.docVP.MouseWheelEnabled = true
d.formVP.MouseWheelEnabled = true
return d
}
func newDetailStyles() detailStyles {
btn := lipgloss.NewStyle().Padding(0, 1).MarginRight(1)
return detailStyles{
sep: lipgloss.NewStyle().Foreground(lipgloss.Color("238")),
action: btn.Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")),
actionOn: btn.Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
actionDn: btn.Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
empty: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
}
}
func (d *DetailPane) Clear() {
d.mode = detailEmpty
d.item = model.Item{}
d.loadedID = ""
d.actions = nil
d.actionIdx = 0
d.tabStop = 0
d.docText = ""
d.form.Clear()
d.docVP.SetContent("")
d.formVP.SetContent("")
}
func (d *DetailPane) SetFocused(on bool) {
d.focused = on
d.applyTabStop(d.tabStop)
}
func (d *DetailPane) FocusFirstStop() {
d.tabStop = 0
d.applyTabStop(0)
}
func (d *DetailPane) maxTabStop() int {
switch d.mode {
case detailForm:
if d.form.readOnly {
if len(d.actions) == 0 {
return 0
}
return len(d.actions) - 1
}
fields := d.form.FieldCount()
if len(d.actions) == 0 {
if fields == 0 {
return 0
}
return fields - 1
}
return fields + len(d.actions) - 1
case detailDocument:
if len(d.actions) == 0 {
return 0
}
return len(d.actions)
default:
return 0
}
}
func (d *DetailPane) applyTabStop(stop int) {
if stop < 0 {
stop = 0
}
max := d.maxTabStop()
if stop > max {
stop = max
}
d.tabStop = stop
switch d.mode {
case detailForm:
if d.form.readOnly {
d.zone = detailZoneActions
d.actionIdx = stop
d.form.SetFocused(false)
return
}
fields := d.form.FieldCount()
if stop < fields {
d.zone = detailZoneContent
d.form.SetFieldIndex(stop)
d.form.SetFocused(d.focused)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - fields
d.form.SetFocused(false)
case detailDocument:
if stop == 0 {
d.zone = detailZoneContent
d.form.SetFocused(false)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - 1
d.form.SetFocused(false)
default:
d.zone = detailZoneContent
d.form.SetFocused(false)
}
}
// TabForward moves to the next field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabForward() bool {
max := d.maxTabStop()
if d.tabStop < max {
d.applyTabStop(d.tabStop + 1)
return false
}
d.FocusFirstStop()
return true
}
// TabBackward moves to the previous field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabBackward() bool {
if d.tabStop > 0 {
d.applyTabStop(d.tabStop - 1)
return false
}
d.applyTabStop(d.maxTabStop())
return true
}
func (d *DetailPane) SetSize(w, h int) {
if w < 8 {
w = 8
}
if h < 8 {
h = 8
}
d.width = w
d.height = h
contentH, _ := d.splitHeights()
d.form.SetSize(w, contentH)
d.docVP.Width = w
d.docVP.Height = contentH
d.formVP.Width = w
d.formVP.Height = contentH
}
func (d *DetailPane) splitHeights() (contentH, actionH int) {
actionH = int(float64(d.height) * (1 - detailContentRatio))
if actionH < 3 {
actionH = 3
}
if actionH > 6 {
actionH = 6
}
contentH = d.height - actionH - 1
if contentH < 4 {
contentH = 4
}
return contentH, actionH
}
func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
d.mode = detailForm
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.tabStop = 0
d.form.Load(item.Kind, item.ID, fields)
d.refreshFormViewport()
d.applyTabStop(0)
d.layoutContent()
}
func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth int) {
d.mode = detailDocument
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.tabStop = 0
d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil {
text = markdown
}
d.docText = text
d.docVP.SetContent(text)
d.docVP.GotoTop()
d.applyTabStop(0)
d.layoutContent()
}
func (d *DetailPane) layoutContent() {
contentH, _ := d.splitHeights()
d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width
d.docVP.Height = contentH
d.formVP.Width = d.width
d.formVP.Height = contentH
d.refreshFormViewport()
}
func (d *DetailPane) refreshFormViewport() {
if d.mode != detailForm || !d.form.readOnly {
d.formVP.SetContent("")
return
}
d.formVP.SetContent(d.form.View())
}
func (d *DetailPane) LoadedID() string { return d.loadedID }
func (d *DetailPane) Item() model.Item { return d.item }
func (d *DetailPane) Zone() detailZone { return d.zone }
func (d *DetailPane) FocusContent() {
d.applyTabStop(d.tabStop)
if d.zone == detailZoneContent && d.mode == detailForm {
d.form.SetFocused(d.focused)
}
}
func (d *DetailPane) FocusActions() {
max := d.maxTabStop()
switch d.mode {
case detailForm:
if d.form.readOnly {
d.applyTabStop(max)
return
}
if len(d.actions) > 0 {
d.applyTabStop(d.form.FieldCount())
}
case detailDocument:
if len(d.actions) > 0 {
d.applyTabStop(1)
}
}
}
// MoveTabStop moves between title, description, and action buttons without leaving the pane.
func (d *DetailPane) MoveTabStop(delta int) {
if delta == 0 {
return
}
next := d.tabStop + delta
if next < 0 {
next = 0
}
max := d.maxTabStop()
if next > max {
next = max
}
d.applyTabStop(next)
}
func (d *DetailPane) MoveAction(delta int) {
if len(d.actions) == 0 {
return
}
d.actionIdx = clampInt(d.actionIdx+delta, 0, len(d.actions)-1)
}
func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
if d.actionIdx < 0 || d.actionIdx >= len(d.actions) {
return model.ItemAction{}, false
}
return d.actions[d.actionIdx], true
}
func (d DetailPane) IsDocumentContent() bool {
return d.mode == detailDocument
}
func (d DetailPane) isReadOnlyFormContent() bool {
return d.mode == detailForm && d.form.readOnly
}
func (d DetailPane) documentContentHeight() int {
contentH, _ := d.splitHeights()
return contentH
}
// ScrollDocument scrolls the read-only document viewport (mail, file preview).
func (d *DetailPane) ScrollDocument(key string) bool {
if d.IsDocumentContent() {
return scrollViewport(&d.docVP, key)
}
if d.isReadOnlyFormContent() && d.zone == detailZoneContent {
return scrollViewport(&d.formVP, key)
}
return false
}
// ScrollDocumentMouse applies wheel events to scrollable preview content.
func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd {
var vp *viewport.Model
switch {
case d.IsDocumentContent():
vp = &d.docVP
case d.isReadOnlyFormContent():
vp = &d.formVP
default:
return nil
}
if msg.Y >= d.documentContentHeight() {
return nil
}
if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
return nil
}
switch msg.Button {
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
default:
return nil
}
var cmd tea.Cmd
*vp, cmd = vp.Update(msg)
return cmd
}
// DocumentYOffset exposes scroll position for tests.
func (d DetailPane) DocumentYOffset() int {
return d.docVP.YOffset
}
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused {
return nil
}
if d.zone == detailZoneContent {
switch d.mode {
case detailForm:
if d.form.readOnly {
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.formVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
d.formVP, cmd = d.formVP.Update(msg)
return cmd
}
return d.form.Update(msg)
case detailDocument:
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.docVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
d.docVP, cmd = d.docVP.Update(msg)
return cmd
}
}
return nil
}
func (d DetailPane) View() string {
if d.mode == detailEmpty {
return d.styles.empty.Render("Select a row to preview or edit.")
}
contentH, actionH := d.splitHeights()
var top string
switch d.mode {
case detailForm:
if d.form.readOnly {
top = ApplyVerticalScrollbar(
d.formVP.View(),
d.formVP.Width,
d.formVP.Height,
d.formVP.TotalLineCount(),
d.formVP.YOffset,
)
} else {
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
}
case detailDocument:
top = ApplyVerticalScrollbar(
d.docVP.View(),
d.docVP.Width,
d.docVP.Height,
d.docVP.TotalLineCount(),
d.docVP.YOffset,
)
default:
top = ""
}
sep := d.styles.sep.Width(d.width).Render(strings.Repeat("─", max(1, d.width)))
bottom := lipgloss.NewStyle().Width(d.width).Height(actionH).Render(d.renderActions())
return lipgloss.JoinVertical(lipgloss.Left, top, sep, bottom)
}
func (d DetailPane) renderActions() string {
if len(d.actions) == 0 {
hint := "No actions"
if d.focused && d.zone == detailZoneActions {
hint = lipgloss.NewStyle().Background(lipgloss.Color("238")).Render(" No actions ")
}
return d.styles.empty.Render(hint)
}
parts := make([]string, 0, len(d.actions))
for i, act := range d.actions {
style := d.styles.action
if d.focused && d.zone == detailZoneActions && i == d.actionIdx {
if act.Danger {
style = d.styles.actionDn
} else {
style = d.styles.actionOn
}
} else if act.Danger {
style = d.styles.actionDn
}
parts = append(parts, style.Render(act.Label))
}
line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" Tab · Enter run")
}
return line
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (d DetailPane) BlinkCmd() tea.Cmd {
return entityFormBlinkCmd()
}
// NewDetailPaneForTest exposes detail pane for tests.
func NewDetailPaneForTest() DetailPane {
return newDetailPane()
}
+60
View File
@@ -0,0 +1,60 @@
package ui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailDocumentScrollByKey(t *testing.T) {
d := newDetailPane()
d.SetSize(50, 20)
longBody := "# Mail\n\n"
for i := 0; i < 80; i++ {
longBody += "line\n"
}
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
d.SetFocused(true)
if d.DocumentYOffset() != 0 {
t.Fatalf("expected top offset 0, got %d", d.DocumentYOffset())
}
if !d.ScrollDocument("down") {
t.Fatal("expected scroll down to succeed")
}
if d.DocumentYOffset() == 0 {
t.Fatal("expected offset after scroll down")
}
if !d.ScrollDocument("up") {
t.Fatal("expected scroll up to succeed")
}
}
func TestDetailDocumentMouseWheel(t *testing.T) {
d := newDetailPane()
d.SetSize(50, 20)
longBody := "# Mail\n\n"
for i := 0; i < 80; i++ {
longBody += "line\n"
}
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
d.ScrollDocumentMouse(tea.MouseMsg{
X: 10, Y: 2, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress,
})
if d.DocumentYOffset() == 0 {
t.Fatal("expected wheel down to scroll document")
}
}
func TestDetailPaneXRange(t *testing.T) {
pw := PaneWidths{
Menu: 12, List: 72, Detail: 36,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
start, end := DetailPaneXRange(pw)
if start != 84 || end != 120 {
t.Fatalf("range=%d..%d want 84..120", start, end)
}
}
+65
View File
@@ -0,0 +1,65 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailTabOrderProjectForm(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject, Title: "P"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Alpha", Secondary: "Beta", HasStatus: true,
Status: model.ProjectLifecycleOpen,
})
if d.tabStop != 0 || d.form.field != entityFieldPrimary {
t.Fatalf("start on title, tabStop=%d field=%d", d.tabStop, d.form.field)
}
if d.TabForward() {
t.Fatal("tab from title should stay in pane")
}
if d.form.field != entityFieldSecondary {
t.Fatalf("second stop should be description, field=%d", d.form.field)
}
if d.TabForward() {
t.Fatal("tab from description should stay in pane")
}
if d.form.field != entityFieldStatus {
t.Fatalf("third stop should be status, field=%d", d.form.field)
}
if d.TabForward() {
t.Fatal("tab from status should stay in pane")
}
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
t.Fatalf("fourth stop should be Save, zone=%d idx=%d", d.Zone(), d.actionIdx)
}
if d.TabForward() {
t.Fatal("tab from Save should stay in pane")
}
if d.actionIdx != 1 {
t.Fatalf("fifth stop should be Delete, idx=%d", d.actionIdx)
}
if !d.TabForward() {
t.Fatal("tab from Delete should leave pane")
}
if d.tabStop != 0 {
t.Fatalf("after leaving pane tab order resets, tabStop=%d", d.tabStop)
}
}
func TestDetailShiftTabFromTitleLeavesPane(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
})
if !d.TabBackward() {
t.Fatal("shift+tab from title should leave pane")
}
if d.tabStop != d.maxTabStop() {
t.Fatalf("should park on last stop, tabStop=%d max=%d", d.tabStop, d.maxTabStop())
}
}
+49
View File
@@ -0,0 +1,49 @@
package ui_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestEntityFormLoadAndView(t *testing.T) {
form := ui.NewEntityFormForTest()
form.SetSize(40, 16)
form.Load(model.KindTask, "42", model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Fix bug", Secondary: "Details here",
})
view := form.View()
if !strings.Contains(view, "Fix bug") {
t.Fatal("view missing title")
}
}
func TestDetailPaneFormAndActions(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindTask, Title: "T"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Hello", Secondary: "World",
})
view := d.View()
if !strings.Contains(view, "Save") {
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")
}
}
+331
View File
@@ -0,0 +1,331 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
type entityField int
const (
entityFieldPrimary entityField = iota
entityFieldSecondary
entityFieldStatus
)
// EntityForm is the top section of the detail pane for editable entities.
type EntityForm struct {
active bool
kind model.Kind
itemID string
primaryLabel string
secondaryLabel string
readOnly bool
hasStatus bool
status model.ProjectLifecycle
responsibleID string
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) FieldCount() int {
n := 2
if f.hasStatus {
n = 3
}
return n
}
func (f *EntityForm) FormFields() model.FormFields {
return model.FormFields{
PrimaryLabel: f.primaryLabel,
SecondaryLabel: f.secondaryLabel,
Primary: f.primary.Value(),
Secondary: f.secondary.Value(),
ReadOnly: f.readOnly,
HasStatus: f.hasStatus,
Status: f.status,
ResponsibleID: f.responsibleID,
}
}
func (f *EntityForm) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID }
func (f *EntityForm) Primary() string { return f.primary.Value() }
func (f *EntityForm) Secondary() string { return f.secondary.Value() }
func (f *EntityForm) MarkClean() { f.dirty = false }
func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormFields) {
f.active = true
f.kind = kind
f.itemID = itemID
f.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus
f.status = fields.Status
f.responsibleID = fields.ResponsibleID
f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary)
f.dirty = false
f.field = entityFieldPrimary
if f.hasStatus && f.readOnly {
f.field = entityFieldStatus
}
f.applyFocus()
f.layoutFields()
}
func (f *EntityForm) Clear() {
f.active = false
f.itemID = ""
f.hasStatus = false
f.status = model.ProjectLifecycleOpen
f.responsibleID = ""
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() {
last := entityFieldSecondary
if f.hasStatus {
last = entityFieldStatus
}
if f.field < last {
f.field++
}
f.applyFocus()
}
func (f *EntityForm) FocusPrev() {
first := entityFieldPrimary
if f.field > first {
f.field--
}
f.applyFocus()
}
func (f *EntityForm) SetFieldIndex(i int) {
switch i {
case 0:
f.field = entityFieldPrimary
case 1:
f.field = entityFieldSecondary
case 2:
if f.hasStatus {
f.field = entityFieldStatus
}
default:
return
}
f.applyFocus()
}
func (f *EntityForm) cycleStatus(delta int) {
if !f.hasStatus || f.readOnly {
return
}
if delta > 0 {
f.status = f.status.Next()
} else {
f.status = f.status.Prev()
}
f.dirty = true
}
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()
case entityFieldSecondary:
f.primary.Blur()
f.secondary.Focus()
default:
f.primary.Blur()
f.secondary.Blur()
}
}
func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus {
switch key.String() {
case "left", "h":
f.cycleStatus(-1)
return nil
case "right", "l":
f.cycleStatus(1)
return nil
case " ":
f.cycleStatus(1)
return nil
}
}
var cmd tea.Cmd
switch f.field {
case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg)
case entityFieldSecondary:
f.secondary, cmd = f.secondary.Update(msg)
default:
return nil
}
if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true
}
return cmd
}
func (f EntityForm) View() string {
if !f.active {
return ""
}
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
pLabel := f.styles.label.Render(f.primaryLabel)
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
pLabel = f.styles.labelAct.Render(f.primaryLabel)
}
sLabel := f.styles.label.Render(f.secondaryLabel)
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
statusBlock := ""
if f.hasStatus {
stLabel := f.styles.label.Render("Status")
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
valStyle := f.styles.label
if f.status == model.ProjectLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.field == entityFieldStatus && !f.readOnly {
hint = f.styles.label.Render(" ←/→ toggle")
}
statusBlock = strings.Join([]string{
"",
stLabel,
valStyle.Render(f.status.Label()) + hint,
}, "\n")
}
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(),
statusBlock,
}, "\n")
}
func entityFormBlinkCmd() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink)
}
// NewEntityFormForTest exposes a form for unit tests.
func NewEntityFormForTest() EntityForm {
return newEntityForm()
}
+88
View File
@@ -0,0 +1,88 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FilterSearch is the right-pane query input for nav/list filtering.
type FilterSearch struct {
input textinput.Model
width int
height int
focused bool
styles filterSearchStyles
}
type filterSearchStyles struct {
title lipgloss.Style
hint lipgloss.Style
}
func newFilterSearch() FilterSearch {
in := textinput.New()
in.Prompt = "/ "
in.Placeholder = "Filter navigation and list…"
in.CharLimit = 256
return FilterSearch{
input: in,
styles: filterSearchStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
},
}
}
func (f *FilterSearch) Query() string { return f.input.Value() }
func (f *FilterSearch) SetFocused(on bool) {
f.focused = on
if on {
f.input.Focus()
} else {
f.input.Blur()
}
}
func (f *FilterSearch) SetSize(w, h int) {
if w < 12 {
w = 12
}
if h < 6 {
h = 6
}
f.width = w
f.height = h
inner := w - 2
if inner < 8 {
inner = 8
}
f.input.Width = inner
}
func (f *FilterSearch) Clear() {
f.input.SetValue("")
}
func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
if !f.focused {
return nil
}
var cmd tea.Cmd
f.input, cmd = f.input.Update(msg)
return cmd
}
func (f FilterSearch) View() string {
title := f.styles.title.Render("Filter")
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
hint := f.styles.hint.Render("Filters left nav and center table · Esc clear · f focus")
return strings.Join([]string{title, "", body, "", hint}, "\n")
}
func filterSearchBlinkCmd() tea.Cmd {
return textinput.Blink
}
+88
View File
@@ -0,0 +1,88 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// Action is a keyboard command outcome for the TUI.
type Action int
const (
ActionNone Action = iota
ActionMoveUp
ActionMoveDown
ActionMoveLeft
ActionMoveRight
ActionSort
ActionToggleSelect
ActionFocusDetail
ActionToggleMenuPane
ActionToggleListPane
ActionToggleDetailPane
ActionFilter
ActionNextPane
ActionPrevPane
ActionRefresh
ActionQuit
)
// KeyAction maps a key string and focused pane to an action.
func KeyAction(key string, pane model.FocusPane) Action {
switch key {
case "q", "ctrl+c":
return ActionQuit
case "tab":
if pane != model.FocusPreview {
return ActionNextPane
}
case "shift+tab", "backtab":
if pane != model.FocusPreview {
return ActionPrevPane
}
case "r":
return ActionRefresh
case "f":
return ActionFilter
case "up", "k":
return ActionMoveUp
case "down", "j":
return ActionMoveDown
case "s":
if pane == model.FocusList {
return ActionSort
}
case "left", "h":
return ActionMoveLeft
case "right", "l":
return ActionMoveRight
case " ":
if pane == model.FocusList {
return ActionToggleSelect
}
case "v", "p":
if pane == model.FocusList {
return ActionFocusDetail
}
case "alt+1":
return ActionToggleMenuPane
case "alt+2":
return ActionToggleListPane
case "alt+3":
return ActionToggleDetailPane
}
return ActionNone
}
// LayoutWidthsLegacy is kept for tests that expect the old signature.
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
pw := LayoutWidths(total, defaultPaneVisibility())
return pw.Menu, pw.List, pw.Detail
}
// ResolveMoveUp returns the action for upward navigation keys.
func ResolveMoveUp(pane model.FocusPane) Action {
switch pane {
case model.FocusMenu, model.FocusList, model.FocusPreview:
return ActionMoveUp
default:
return ActionNone
}
}
+145
View File
@@ -0,0 +1,145 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// PaneVisibility controls which columns are shown.
type PaneVisibility struct {
Menu, List, Detail bool
}
// PaneWidths is outer lipgloss width per column (sums to terminal width).
type PaneWidths struct {
Menu, List, Detail int
Visibility PaneVisibility
}
func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true}
}
const (
defaultMenuShare = 10
defaultListShare = 60
defaultDetailShare = 30
)
// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%).
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 {
total = 80
}
v := vis
if countVisible(v) == 0 {
v = defaultPaneVisibility()
}
shareSum := 0
if v.Menu {
shareSum += defaultMenuShare
}
if v.List {
shareSum += defaultListShare
}
if v.Detail {
shareSum += defaultDetailShare
}
assign := func(visible bool, share int) int {
if !visible || shareSum == 0 {
return 0
}
return total * share / shareSum
}
out := PaneWidths{Visibility: v}
out.Menu = assign(v.Menu, defaultMenuShare)
out.List = assign(v.List, defaultListShare)
out.Detail = assign(v.Detail, defaultDetailShare)
fixPaneWidthSum(&out, total)
return out
}
// DetailPaneXRange returns the [start, end) column span of the detail pane.
func DetailPaneXRange(pw PaneWidths) (start, end int) {
if !pw.Visibility.Detail {
return 0, 0
}
if pw.Visibility.Menu {
start += pw.Menu
}
if pw.Visibility.List {
start += pw.List
}
end = start + pw.Detail
return start, end
}
func countVisible(v PaneVisibility) int {
n := 0
if v.Menu {
n++
}
if v.List {
n++
}
if v.Detail {
n++
}
return n
}
// NextVisibleFocus cycles focus forward, skipping hidden panes.
func NextVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start+step)%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
// PrevVisibleFocus cycles focus backward, skipping hidden panes.
func PrevVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start-step+len(order))%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
func paneVisible(p model.FocusPane, vis PaneVisibility) bool {
switch p {
case model.FocusMenu:
return vis.Menu
case model.FocusList:
return vis.List
default:
return vis.Detail
}
}
func firstVisibleFocus(vis PaneVisibility) model.FocusPane {
if vis.Menu {
return model.FocusMenu
}
if vis.List {
return model.FocusList
}
return model.FocusPreview
}
+203
View File
@@ -0,0 +1,203 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
const (
minPaneOuterWidth = 14
resizeHitSlop = 1
)
type paneResizeState struct {
active bool
divider int
anchorX int
anchorPW PaneWidths
}
// FitPaneWidths scales stored pane widths to fill total width for visible panes.
func FitPaneWidths(total int, vis PaneVisibility, sizes PaneWidths) PaneWidths {
if total < 1 {
total = 80
}
out := PaneWidths{Visibility: vis}
if countVisible(vis) == 0 {
return LayoutWidths(total, vis)
}
sum := 0
if vis.Menu {
sum += sizes.Menu
}
if vis.List {
sum += sizes.List
}
if vis.Detail {
sum += sizes.Detail
}
if sum <= 0 {
return LayoutWidths(total, vis)
}
assign := func(visible bool, stored int) int {
if !visible {
return 0
}
return stored * total / sum
}
out.Menu = assign(vis.Menu, sizes.Menu)
out.List = assign(vis.List, sizes.List)
out.Detail = assign(vis.Detail, sizes.Detail)
fixPaneWidthSum(&out, total)
return out
}
func fixPaneWidthSum(pw *PaneWidths, total int) {
if pw == nil || total < 1 {
return
}
type slot struct {
visible bool
width *int
}
slots := []slot{
{pw.Visibility.Menu, &pw.Menu},
{pw.Visibility.List, &pw.List},
{pw.Visibility.Detail, &pw.Detail},
}
sum := 0
var last *int
for _, s := range slots {
if !s.visible {
continue
}
sum += *s.width
last = s.width
}
if last == nil {
return
}
*last += total - sum
if *last < minPaneOuterWidth {
*last = minPaneOuterWidth
}
}
// DividerPositions returns x coordinates of vertical pane separators.
func DividerPositions(pw PaneWidths) []int {
var out []int
x := 0
if pw.Visibility.Menu {
x += pw.Menu
if pw.Visibility.List || pw.Visibility.Detail {
out = append(out, x)
}
}
if pw.Visibility.List {
x += pw.List
if pw.Visibility.Detail {
out = append(out, x)
}
}
return out
}
// DividerAt returns the divider index under x, or -1.
func DividerAt(x int, pw PaneWidths) int {
for i, pos := range DividerPositions(pw) {
if x >= pos-resizeHitSlop && x <= pos+resizeHitSlop {
return i
}
}
return -1
}
// DragPaneDivider adjusts adjacent pane widths by deltaX pixels.
func DragPaneDivider(pw PaneWidths, divider, deltaX int) (PaneWidths, bool) {
if deltaX == 0 {
return pw, false
}
changed := false
switch divider {
case 0:
if pw.Visibility.Menu && pw.Visibility.List {
if resizePair(&pw.Menu, &pw.List, deltaX) {
changed = true
}
} else if pw.Visibility.Menu && pw.Visibility.Detail {
if resizePair(&pw.Menu, &pw.Detail, deltaX) {
changed = true
}
}
case 1:
if pw.Visibility.List && pw.Visibility.Detail {
if resizePair(&pw.List, &pw.Detail, deltaX) {
changed = true
}
}
}
return pw, changed
}
func resizePair(left, right *int, delta int) bool {
if left == nil || right == nil {
return false
}
newLeft := *left + delta
newRight := *right - delta
if newLeft < minPaneOuterWidth || newRight < minPaneOuterWidth {
return false
}
*left = newLeft
*right = newRight
return true
}
func (m Model) paneResizeMaxY() int {
return m.paneHeight() + 1
}
func (m *Model) handlePaneResizeMouse(msg tea.MouseMsg) bool {
if msg.Y >= m.paneResizeMaxY() {
if m.resize.active && msg.Action == tea.MouseActionRelease {
m.resize.active = false
return true
}
return false
}
pw := m.paneLayout()
switch msg.Action {
case tea.MouseActionPress:
if msg.Button != tea.MouseButtonLeft {
return false
}
if div := DividerAt(msg.X, pw); div >= 0 {
m.paneSizes = pw
m.customPaneLayout = true
m.resize = paneResizeState{
active: true,
divider: div,
anchorX: msg.X,
anchorPW: pw,
}
return true
}
case tea.MouseActionRelease:
if m.resize.active {
m.resize.active = false
return true
}
case tea.MouseActionMotion:
if m.resize.active {
delta := msg.X - m.resize.anchorX
next, ok := DragPaneDivider(m.resize.anchorPW, m.resize.divider, delta)
if ok {
m.paneSizes = next
m.layoutViewports()
m.syncAllContent()
}
return true
}
}
return m.resize.active
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import "testing"
func TestDividerAtBetweenMenuAndList(t *testing.T) {
pw := PaneWidths{
Menu: 30, List: 50, Detail: 40,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
if got := DividerAt(30, pw); got != 0 {
t.Fatalf("divider at 30=%d want 0", got)
}
if got := DividerAt(80, pw); got != 1 {
t.Fatalf("divider at 80=%d want 1", got)
}
}
func TestDragPaneDividerRespectsMinimum(t *testing.T) {
pw := PaneWidths{
Menu: 40, List: 40, Detail: 40,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
next, ok := DragPaneDivider(pw, 0, -30)
if ok {
t.Fatalf("expected resize blocked by minimum, got %+v", next)
}
next, ok = DragPaneDivider(pw, 0, 5)
if !ok || next.Menu != 45 || next.List != 35 {
t.Fatalf("resize failed: ok=%v %+v", ok, next)
}
}
func TestFitPaneWidthsScalesToTotal(t *testing.T) {
vis := PaneVisibility{Menu: true, List: true, Detail: true}
sizes := PaneWidths{Menu: 20, List: 30, Detail: 50, Visibility: vis}
out := FitPaneWidths(120, vis, sizes)
sum := out.Menu + out.List + out.Detail
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
}
+86
View File
@@ -0,0 +1,86 @@
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)
}
if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 {
t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail)
}
}
func TestLayoutWidthsSinglePane(t *testing.T) {
pw := LayoutWidths(100, PaneVisibility{Menu: false, List: true, Detail: false})
if pw.List != 100 {
t.Fatalf("list=%d want 100", pw.List)
}
if pw.Menu != 0 || pw.Detail != 0 {
t.Fatalf("unexpected widths: %+v", pw)
}
}
func TestLayoutWidthsTwoPanes(t *testing.T) {
pw := LayoutWidths(80, PaneVisibility{Menu: true, List: false, Detail: true})
if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
}
if pw.Menu != 20 || pw.Detail != 60 {
t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail)
}
}
func TestDetailPaneXRangeSkipsHidden(t *testing.T) {
pw := PaneWidths{
Menu: 20, List: 60, Detail: 0,
Visibility: PaneVisibility{Menu: true, List: true, Detail: false},
}
start, end := DetailPaneXRange(pw)
if start != 0 || end != 0 {
t.Fatalf("hidden detail should have empty range, got %d..%d", start, end)
}
}
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: false, List: true, Detail: true}
if got := NextVisibleFocus(model.FocusList, vis); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
if got := NextVisibleFocus(model.FocusPreview, vis); got != model.FocusList {
t.Fatalf("got %v", got)
}
}
func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: true, List: false, Detail: true}
if got := PrevVisibleFocus(model.FocusPreview, vis); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
}
func 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)
}
}
+73
View File
@@ -0,0 +1,73 @@
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", "ctrl+d":
m.listTable.PageScroll(1)
return true
case "pgup", "b", "ctrl+u":
m.listTable.PageScroll(-1)
return true
}
return false
case model.FocusPreview:
if m.detail.Zone() != detailZoneContent {
return false
}
return m.detail.ScrollDocument(key)
default:
return false
}
}
func (m *Model) paneHeight() int {
h := m.height - 3
if h < 3 {
h = 3
}
return h
}
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 "up", "k":
vp.LineUp(1)
return true
case "down", "j":
vp.LineDown(1)
return true
case "pgdown", "pgdn", "ctrl+d":
vp.ViewDown()
return true
case "pgup", "b", "ctrl+u":
vp.ViewUp()
return true
case "home", "g":
vp.GotoTop()
return true
case "end", "G":
vp.GotoBottom()
return true
}
return false
}
+76
View File
@@ -0,0 +1,76 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
func ScrollbarMetrics(totalLines, viewportHeight, yOffset int) (show bool, thumbStart, thumbEnd int) {
if totalLines <= viewportHeight || viewportHeight < 1 {
return false, 0, 0
}
maxOff := totalLines - viewportHeight
if maxOff < 0 {
maxOff = 0
}
thumbSize := viewportHeight * viewportHeight / totalLines
if thumbSize < 1 {
thumbSize = 1
}
if thumbSize > viewportHeight {
thumbSize = viewportHeight
}
travel := viewportHeight - thumbSize
thumbStart = 0
if maxOff > 0 {
thumbStart = yOffset * travel / maxOff
}
return true, thumbStart, thumbStart + thumbSize
}
// ApplyVerticalScrollbar draws a 1-column scrollbar on the right when content overflows.
func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) string {
show, thumbStart, thumbEnd := ScrollbarMetrics(totalLines, height, yOffset)
if !show || width < 2 {
return view
}
contentWidth := width - 1
lines := strings.Split(view, "\n")
for len(lines) < height {
lines = append(lines, "")
}
if len(lines) > height {
lines = lines[:height]
}
track := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
out := make([]string, height)
for row := 0; row < height; row++ {
line := padDisplayWidth(lines[row], contentWidth)
ch := "│"
style := track
if row >= thumbStart && row < thumbEnd {
ch = "█"
style = thumb
}
out[row] = line + style.Render(ch)
}
return strings.Join(out, "\n")
}
func padDisplayWidth(line string, width int) string {
if width < 1 {
return ""
}
w := runewidth.StringWidth(line)
if w > width {
return runewidth.Truncate(line, width, "")
}
if w < width {
line += strings.Repeat(" ", width-w)
}
return line
}
+32
View File
@@ -0,0 +1,32 @@
package ui
import (
"strings"
"testing"
)
func TestScrollbarMetricsHiddenWhenFits(t *testing.T) {
show, _, _ := ScrollbarMetrics(5, 10, 0)
if show {
t.Fatal("expected no scrollbar when content fits")
}
}
func TestScrollbarMetricsVisibleWhenOverflow(t *testing.T) {
show, start, end := ScrollbarMetrics(100, 10, 50)
if !show || end <= start {
t.Fatalf("expected thumb, got %d..%d", start, end)
}
}
func TestApplyVerticalScrollbarAddsColumn(t *testing.T) {
view := "line1\nline2\nline3"
out := ApplyVerticalScrollbar(view, 12, 3, 10, 0)
lines := strings.Split(out, "\n")
if len(lines) != 3 {
t.Fatalf("lines=%d", len(lines))
}
if !strings.HasSuffix(lines[0], "█") && !strings.HasSuffix(lines[0], "│") {
t.Fatalf("expected scrollbar glyph: %q", lines[0])
}
}
+595
View File
@@ -0,0 +1,595 @@
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
loadingMore 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]
}
// CursorRow is the visual row index in the current table order.
func (t DataTable) CursorRow() int {
return t.cursorRow
}
// Items returns the backing slice (after appends).
func (t DataTable) Items() []model.Item {
return t.items
}
// NearEnd reports whether the cursor is within threshold rows of the last row.
func (t DataTable) NearEnd(threshold int) bool {
if !t.ready || len(t.order) == 0 {
return false
}
return t.cursorRow >= len(t.order)-threshold
}
func (t *DataTable) SetLoadingMore(on bool) {
t.loadingMore = on
}
// AppendItems adds new rows, skipping duplicate IDs. Returns how many were added.
func (t *DataTable) AppendItems(items []model.Item) int {
if !t.ready || len(items) == 0 {
return 0
}
seen := make(map[string]struct{}, len(t.items))
for _, it := range t.items {
if it.ID != "" {
seen[it.ID] = struct{}{}
}
}
added := 0
for _, it := range items {
if it.ID != "" {
if _, ok := seen[it.ID]; ok {
continue
}
seen[it.ID] = struct{}{}
}
idx := len(t.items)
t.items = append(t.items, it)
t.order = append(t.order, idx)
added++
}
if added > 0 {
if t.sortCol >= 0 {
t.applySort()
}
t.clampCursor()
t.refreshViewport()
}
return added
}
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", "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)))
if t.loadingMore {
title += t.styles.help.Render(" …")
}
sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲"
if !t.sortAsc {
dir = "▼"
}
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
}
header := t.renderHeader()
body := ApplyVerticalScrollbar(
t.viewport.View(),
t.viewport.Width,
t.viewport.Height,
t.viewport.TotalLineCount(),
t.viewport.YOffset,
)
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content)
}
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
widths = make(map[int]int)
if len(t.columns) == 0 || t.width <= 0 {
return nil, widths
}
indices = t.pickVisibleColumnIndices()
if len(indices) == 0 {
return indices, widths
}
minSum := 0
for _, i := range indices {
minSum += t.columns[i].Width
}
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
return indices, widths
}
func (t *DataTable) pickVisibleColumnIndices() []int {
var out []int
used := 0
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
w := t.columns[colIdx].Width
if len(out) > 0 && used+w > t.width {
break
}
out = append(out, colIdx)
used += w
}
if len(out) == 0 {
colIdx := t.colScroll
if colIdx < 0 || colIdx >= len(t.columns) {
colIdx = 0
}
out = []int{colIdx}
}
return out
}
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
out := make(map[int]int, len(indices))
if len(indices) == 0 {
return out
}
if total < len(indices) {
total = len(indices)
}
if minSum <= 0 {
each := total / len(indices)
if each < 1 {
each = 1
}
for _, i := range indices {
out[i] = each
}
fixColumnWidthSum(out, indices, total)
return out
}
for _, i := range indices {
out[i] = cols[i].Width
}
if minSum >= total {
for _, i := range indices {
out[i] = cols[i].Width * total / minSum
if out[i] < 1 {
out[i] = 1
}
}
fixColumnWidthSum(out, indices, total)
return out
}
extra := total - minSum
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
flex = append(flex, i)
}
}
if len(flex) == 0 {
flex = append(flex, indices...)
}
flexSum := 0
for _, i := range flex {
flexSum += cols[i].Width
}
if flexSum <= 0 {
flexSum = len(flex)
}
for _, i := range flex {
out[i] += extra * cols[i].Width / flexSum
}
fixColumnWidthSum(out, indices, total)
return out
}
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
if len(indices) == 0 {
return
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
widths[indices[len(indices)-1]] += total - sum
if widths[indices[len(indices)-1]] < 1 {
widths[indices[len(indices)-1]] = 1
}
}
const cellHPadding = 2 // lipgloss Padding(0, 1) on each side
func truncateCellText(text string, colWidth int) string {
inner := colWidth - cellHPadding
if inner < 1 {
inner = 1
}
if runewidth.StringWidth(text) <= inner {
return text
}
return runewidth.Truncate(text, inner, "...")
}
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 := truncateCellText(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
fullRow := row == t.cursorRow || selected
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
raw := strings.TrimSpace(model.CellText(item, col.Key))
text := raw
if !fullRow {
text = truncateCellText(raw, w)
}
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
if full {
return s
}
return s.Width(width).MaxWidth(width)
}
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string {
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
var base lipgloss.Style
switch {
case selected && isCell:
base = t.styles.cellSelect
case selected && isRow:
base = t.styles.rowSelect
case selected:
base = t.styles.rowSelect
case isCell:
base = t.styles.cellActive
case t.focused && isRow:
base = t.styles.rowActive
case t.focused && isCol:
base = t.styles.colActive
default:
base = t.styles.cell
}
base = t.applyProjectRowStyle(base, item, colKey)
return applyCellWidth(base, width, fullWidth).Render(text)
}
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
if t.spec.Subject != model.SubjectProjects {
return base
}
open := model.ProjectIsOpen(item.Raw)
if colKey == "status" {
if open {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
if open {
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
}
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
}
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()
}
+40
View File
@@ -0,0 +1,40 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDataTableAppendItemsDedupes(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(40, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, []model.Item{
{ID: "1", Title: "A", Kind: model.KindMail},
})
added := tbl.AppendItems([]model.Item{
{ID: "1", Title: "dup", Kind: model.KindMail},
{ID: "2", Title: "B", Kind: model.KindMail},
})
if added != 1 || len(tbl.Items()) != 2 {
t.Fatalf("added=%d len=%d", added, len(tbl.Items()))
}
}
func TestDataTableNearEnd(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(40, 10)
items := make([]model.Item, 10)
for i := range items {
items[i] = model.Item{ID: string(rune('0' + i)), Title: "x", Kind: model.KindMail}
}
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, items)
tbl.cursorRow = 7
if !tbl.NearEnd(3) {
t.Fatal("expected near end")
}
tbl.cursorRow = 0
if tbl.NearEnd(3) {
t.Fatal("expected not near end")
}
}
+148
View File
@@ -0,0 +1,148 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
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.SubjectContacts}, 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 TestTruncateCellText(t *testing.T) {
long := strings.Repeat("x", 40)
got := truncateCellText(long, 10)
if runewidth.StringWidth(got) > 10 {
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
}
if !strings.HasSuffix(got, "...") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
}
func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
long := strings.Repeat("A", 60)
items := []model.Item{
{ID: "1", Title: "Short"},
{ID: "2", Title: long},
}
tbl := newDataTable()
tbl.SetSize(50, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.cursorRow = 0
if strings.Contains(tbl.renderRow(1), long) {
t.Fatal("non-cursor row should truncate long title")
}
tbl.cursorRow = 1
if !strings.Contains(tbl.renderRow(1), long) {
t.Fatal("cursor row should show full title")
}
}
func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
long := strings.Repeat("B", 60)
items := []model.Item{
{ID: "1", Title: "Short"},
{ID: "2", Title: long, Selected: true},
}
tbl := newDataTable()
tbl.SetSize(50, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.cursorRow = 0
rowSelected := tbl.renderRow(1)
if !strings.Contains(rowSelected, long) {
t.Fatal("space-selected row should show full title")
}
}
func indexOfColumn(tbl DataTable, key string) int {
for i, c := range tbl.columns {
if c.Key == key {
return i
}
}
return 0
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"time"
"github.com/spf13/cobra"
)
var calendarCmd = &cobra.Command{
Use: "calendar",
Aliases: []string{"cal"},
Short: "Calendars and events",
}
func init() {
rootCmd.AddCommand(calendarCmd)
calendarCmd.AddCommand(calListCmd())
calendarCmd.AddCommand(calEventsCmd())
calendarCmd.AddCommand(calAddCmd())
calendarCmd.AddCommand(calDeleteCmd())
}
func calListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List calendars",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListCalendars(cmd.Context(), "", "")
if err != nil {
return err
}
printTable([]string{"objectId", "title", "textColor", "backgroundColor", "isEditable"}, out)
return nil
},
}
}
func calEventsCmd() *cobra.Command {
var start, end string
cmd := &cobra.Command{
Use: "events",
Short: "List calendar events for a date range (default: next 7 days)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if start == "" || end == "" {
start = time.Now().Format("2006-01-02")
end = time.Now().AddDate(0, 0, 7).Format("2006-01-02")
}
out, err := c.ListEvents(cmd.Context(), start, end)
if err != nil {
return err
}
printTable([]string{"objectId", "title", "start", "end", "allDayLong"}, out)
return nil
},
}
cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD")
cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD")
return cmd
}
func calAddCmd() *cobra.Command {
var cal, desc string
var allDay bool
cmd := &cobra.Command{
Use: "add TITLE START END",
Short: "Add a calendar event",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay)
if err != nil {
return err
}
printObject(ev)
return nil
},
}
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env OO_CALENDAR_ID)")
cmd.Flags().StringVar(&desc, "description", "", "event description")
cmd.Flags().BoolVar(&allDay, "all-day", false, "mark as all-day event")
return cmd
}
func calDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete EVENT_ID [EVENT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more calendar events",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteEvent(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var casesCmd = &cobra.Command{
Use: "cases",
Aliases: []string{"case"},
Short: "CRM cases",
}
func init() {
rootCmd.AddCommand(casesCmd)
casesCmd.AddCommand(casesListCmd())
casesCmd.AddCommand(casesCreateCmd())
casesCmd.AddCommand(casesDeleteCmd())
casesCmd.AddCommand(casesMemberAddCmd())
}
func casesListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "isClosed", "created"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func casesCreateCmd() *cobra.Command {
return &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a case",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCase(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func casesDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete CASE_ID [CASE_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more cases",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteCase(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func casesMemberAddCmd() *cobra.Command {
return &cobra.Command{
Use: "member-add CASE_ID CONTACT_ID",
Short: "Attach a contact to a case",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddCaseMember(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestRootRegistersSubjects(t *testing.T) {
want := []string{
"calendar", "projects", "tasks", "users", "whoami",
"contacts", "persons", "companies",
"opportunities", "cases", "crm-tasks", "applications", "crm", "mails",
}
got := make(map[string]bool, len(rootCmd.Commands()))
for _, c := range rootCmd.Commands() {
got[c.Name()] = true
}
for _, name := range want {
if !got[name] {
t.Fatalf("missing root subcommand %q; have %v", name, rootCmd.Commands())
}
}
}
func TestRootHelpListsSubjects(t *testing.T) {
out := &bytes.Buffer{}
rootCmd.SetOut(out)
rootCmd.SetErr(&bytes.Buffer{})
rootCmd.SetArgs([]string{"--help"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
if err := rootCmd.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, snippet := range []string{"calendar", "projects", "tasks", "users", "opportunities"} {
if !strings.Contains(help, snippet) {
t.Fatalf("help missing %q", snippet)
}
}
}
func TestProjectsAlias(t *testing.T) {
cmd, _, err := rootCmd.Find([]string{"prj"})
if err != nil {
t.Fatal(err)
}
if cmd.Name() != "projects" {
t.Fatalf("prj alias resolved to %q", cmd.Name())
}
}
func TestNewOOReturnsErrorWithoutCredentials(t *testing.T) {
clearEnv(t,
"ONLYOFFICE_URL", "ONLYOFFICE_HOST", "ONLYOFFICE_USER", "ONLYOFFICE_NAME",
"ONLYOFFICE_PASS", "ONLYOFFICE_PASSWORD",
"OO_URL", "OO_USER", "OO_PASS",
)
dir := t.TempDir()
oldwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldwd) })
errBuf := &bytes.Buffer{}
rootCmd.SetErr(errBuf)
rootCmd.SetOut(&bytes.Buffer{})
rootCmd.SetArgs([]string{"users", "list"})
t.Cleanup(func() {
rootCmd.SetArgs(nil)
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
})
err = rootCmd.Execute()
if err == nil {
t.Fatal("expected error without credentials")
}
msg := err.Error()
for _, want := range []string{"ONLYOFFICE_URL", "ONLYOFFICE_USER", "ONLYOFFICE_PASS"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
}
+168
View File
@@ -0,0 +1,168 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
"github.com/spf13/cobra"
)
// outputFormat is the value of the global --output/-o flag. Valid values:
// "table" (default, tabwriter-rendered), "json" (machine-readable).
var outputFormat = "table"
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI — subject-based command tree",
Long: "oo is a thin CLI over github.com/eslider/go-onlyoffice.\nCommands are grouped by OnlyOffice subject (calendar, projects, tasks, users, persons, companies, opportunities, cases, crm-tasks, applications).",
Version: version,
SilenceUsage: true,
SilenceErrors: false,
}
func init() {
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "output format: table|json")
}
// execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() }
// newOO loads env (only .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO(cmd *cobra.Command) (*onlyoffice.Client, error) {
return bootstrap.NewClient(cmd.Context())
}
// printJSON dumps any value as indented JSON.
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// printObject renders a single value. JSON output dumps verbatim; table output
// prints key/value pairs when v is a map, otherwise falls back to JSON.
func printObject(v any) {
if outputFormat == "json" {
printJSON(v)
return
}
m, ok := v.(map[string]any)
if !ok {
printJSON(v)
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
for _, k := range sortedKeys(m) {
fmt.Fprintf(w, "%s\t%s\n", k, fmtCell(m[k]))
}
_ = w.Flush()
}
// printTable renders a list of rows. headers select & order the columns.
// When outputFormat=="json" the raw slice is dumped as-is.
func printTable(headers []string, rows []map[string]any) {
if outputFormat == "json" {
printJSON(rows)
return
}
if len(rows) == 0 {
fmt.Println("(empty)")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, strings.Join(headers, "\t"))
for _, row := range rows {
cells := make([]string, len(headers))
for i, h := range headers {
cells[i] = fmtCell(row[h])
}
fmt.Fprintln(w, strings.Join(cells, "\t"))
}
_ = w.Flush()
}
// fmtCell turns an arbitrary value into a compact table cell.
func fmtCell(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return truncate(x, 80)
case bool:
if x {
return "true"
}
return "false"
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case int, int32, int64:
return fmt.Sprintf("%d", x)
default:
b, _ := json.Marshal(x)
return truncate(string(b), 80)
}
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > n {
return s[:n-1] + "…"
}
return s
}
func sortedKeys(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
// Deterministic ordering; small map so O(n log n) is fine.
for i := 1; i < len(ks); i++ {
for j := i; j > 0 && ks[j-1] > ks[j]; j-- {
ks[j-1], ks[j] = ks[j], ks[j-1]
}
}
return ks
}
// flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int
// / string into a float64.
func flexIDFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
// idString returns the id field as a string (works for int/float/string).
func idString(m map[string]any, key string) string {
v, ok := m[key]
if !ok {
return ""
}
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
case int:
return strconv.Itoa(x)
default:
return fmt.Sprint(x)
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func TestFmtCell(t *testing.T) {
tests := []struct {
in any
want string
}{
{nil, ""},
{"hello", "hello"},
{true, "true"},
{false, "false"},
{float64(42), "42"},
{float64(1.5), "1.5"},
{int64(7), "7"},
{map[string]any{"a": 1}, `{"a":1}`},
}
for _, tc := range tests {
if got := fmtCell(tc.in); got != tc.want {
t.Fatalf("fmtCell(%#v)=%q want %q", tc.in, got, tc.want)
}
}
long := strings.Repeat("x", 100)
got := fmtCell(long)
if !strings.HasSuffix(got, "…") || len(got) > 82 {
t.Fatalf("fmtCell(long)=%q len=%d", got, len(got))
}
}
func TestTruncate(t *testing.T) {
if got := truncate("a\nb", 10); got != "a b" {
t.Fatalf("truncate newline=%q", got)
}
got := truncate(strings.Repeat("z", 20), 10)
if !strings.HasSuffix(got, "…") || len(got) >= 20 {
t.Fatalf("truncate=%q len=%d", got, len(got))
}
}
func TestSortedKeys(t *testing.T) {
got := sortedKeys(map[string]any{"c": 1, "a": 2, "b": 3})
want := []string{"a", "b", "c"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sortedKeys=%v want %v", got, want)
}
}
}
func TestFlexIDFloat(t *testing.T) {
if got := flexIDFloat(float64(12)); got != 12 {
t.Fatalf("float64=%v", got)
}
if got := flexIDFloat(7); got != 7 {
t.Fatalf("int=%v", got)
}
if got := flexIDFloat("3.5"); got != 3.5 {
t.Fatalf("string=%v", got)
}
}
func TestIdString(t *testing.T) {
m := map[string]any{"id": float64(99), "s": "x", "i": 5}
if got := idString(m, "id"); got != "99" {
t.Fatalf("float id=%q", got)
}
if got := idString(m, "s"); got != "x" {
t.Fatalf("string id=%q", got)
}
if got := idString(m, "i"); got != "5" {
t.Fatalf("int id=%q", got)
}
if got := idString(m, "missing"); got != "" {
t.Fatalf("missing=%q", got)
}
}
func TestPrintTableJSON(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "json"
out := captureStdout(t, func() {
printTable([]string{"id", "name"}, []map[string]any{{"id": 1, "name": "alpha"}})
})
if !strings.Contains(out, `"id": 1`) || !strings.Contains(out, `"name": "alpha"`) {
t.Fatalf("json output=%q", out)
}
}
func TestPrintTableEmpty(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() { printTable([]string{"id"}, nil) })
if strings.TrimSpace(out) != "(empty)" {
t.Fatalf("empty table=%q", out)
}
}
func TestPrintObjectTable(t *testing.T) {
defer func(old string) { outputFormat = old }(outputFormat)
outputFormat = "table"
out := captureStdout(t, func() {
printObject(map[string]any{"id": float64(1), "title": "Demo"})
})
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "Demo") {
t.Fatalf("table object=%q", out)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = old
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
_ = r.Close()
return buf.String()
}
func clearEnv(t *testing.T, keys ...string) {
t.Helper()
for _, key := range keys {
old, ok := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if ok {
_ = os.Setenv(key, old)
return
}
_ = os.Unsetenv(key)
})
}
}
+313
View File
@@ -0,0 +1,313 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
// Contacts cover both persons and companies; OnlyOffice exposes them through
// the same `crm/contact/*` endpoint family. `oo persons` and `oo companies`
// are filtered views on top of `oo contacts list`, plus dedicated create
// commands that pick the right library call.
var contactsCmd = &cobra.Command{
Use: "contacts",
Aliases: []string{"contact"},
Short: "CRM contacts (persons + companies)",
}
var personsCmd = &cobra.Command{
Use: "persons",
Aliases: []string{"person"},
Short: "CRM persons (contacts with isCompany=false)",
}
var companiesCmd = &cobra.Command{
Use: "companies",
Aliases: []string{"company"},
Short: "CRM companies (contacts with isCompany=true)",
}
func init() {
rootCmd.AddCommand(contactsCmd)
rootCmd.AddCommand(personsCmd)
rootCmd.AddCommand(companiesCmd)
contactsCmd.AddCommand(contactsListCmd(nil))
contactsCmd.AddCommand(contactsGetCmd())
contactsCmd.AddCommand(contactsDeleteCmd())
contactsCmd.AddCommand(contactsInfoAddCmd())
contactsCmd.AddCommand(contactsDedupeInfoCmd())
only := true
personsCmd.AddCommand(contactsListCmd(&only)) // persons only
personsCmd.AddCommand(personsCreateCmd())
personsCmd.AddCommand(contactsDeleteCmd())
personsCmd.AddCommand(personsDedupeCmd())
onlyCo := false
companiesCmd.AddCommand(contactsListCmd(&onlyCo)) // companies only
companiesCmd.AddCommand(companiesCreateCmd())
companiesCmd.AddCommand(contactsDeleteCmd())
companiesCmd.AddCommand(companiesDedupeCmd())
companiesCmd.AddCommand(companiesDedupePersonsCmd())
}
// contactsListCmd returns a `list` subcommand.
//
// - personsOnly == nil → list all contacts
// - personsOnly == &true → keep only persons (isCompany=false)
// - personsOnly == &false → keep only companies (isCompany=true)
func contactsListCmd(personsOnly *bool) *cobra.Command {
var search string
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListContacts(cmd.Context(), count, offset, search)
if err != nil {
return err
}
filtered := list
if personsOnly != nil {
filtered = filtered[:0]
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if *personsOnly && isCo {
continue
}
if !*personsOnly && !isCo {
continue
}
filtered = append(filtered, row)
}
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(filtered))
}
printTable([]string{"id", "displayName", "isCompany", "email", "companyName"}, filtered)
return nil
},
}
cmd.Flags().StringVar(&search, "search", "", "search filter")
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func contactsGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get CONTACT_ID",
Short: "Show a contact by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetContact(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func contactsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete CONTACT_ID [CONTACT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more contacts",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteContact(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func contactsInfoAddCmd() *cobra.Command {
var infoType, value, category string
var isPrimary bool
cmd := &cobra.Command{
Use: "info-add CONTACT_ID",
Short: "Add a contact info entry (email, phone, website, linkedin, …)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if infoType == "" || value == "" {
return fmt.Errorf("--type and --value are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddContactInfo(cmd.Context(), args[0], infoType, value, category, isPrimary)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&infoType, "type", "", "Email|Phone|Website|LinkedIn|…")
cmd.Flags().StringVar(&value, "value", "", "value")
cmd.Flags().StringVar(&category, "category", "Work", "Work|Home|Other")
cmd.Flags().BoolVar(&isPrimary, "primary", false, "mark as primary")
return cmd
}
func personsCreateCmd() *cobra.Command {
var first, last, email, linkedin string
var companyID int
var jobTitle, about string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a person",
RunE: func(cmd *cobra.Command, args []string) error {
if first == "" || last == "" {
return fmt.Errorf("--first and --last are required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreatePerson(cmd.Context(), first, last, companyID, jobTitle, about)
if err != nil {
return err
}
pid := strconv.Itoa(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&first, "first", "", "first name")
cmd.Flags().StringVar(&last, "last", "", "last name")
cmd.Flags().IntVar(&companyID, "company-id", 0, "employer company id")
cmd.Flags().StringVar(&jobTitle, "job-title", "", "")
cmd.Flags().StringVar(&about, "about", "", "about / bio")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "linkedin url (adds ContactInfo)")
return cmd
}
func companiesCreateCmd() *cobra.Command {
var name, email, website string
cmd := &cobra.Command{
Use: "create",
Aliases: []string{"add"},
Short: "Create a company",
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" {
return fmt.Errorf("--name is required")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCompany(cmd.Context(), name)
if err != nil {
return err
}
cid := strconv.Itoa(int(flexIDFloat(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), cid, "Website", website, "Work", false)
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "company name")
cmd.Flags().StringVar(&email, "email", "", "primary email (adds ContactInfo)")
cmd.Flags().StringVar(&website, "website", "", "website url (adds ContactInfo)")
return cmd
}
func companiesDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate companies by normalized name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanies(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func companiesDedupePersonsCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-persons",
Short: "Merge duplicate persons under each company",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeCompanyPersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func personsDedupeCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate persons by normalized first+last name",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupePersons(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func contactsDedupeInfoCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-info",
Short: "Remove duplicate contact info rows (email, phone, …)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeContactInfo(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var crmCmd = &cobra.Command{
Use: "crm",
Short: "CRM maintenance (dedupe, cleanup)",
}
func init() {
rootCmd.AddCommand(crmCmd)
crmCmd.AddCommand(crmCleanupCmd())
}
func crmCleanupCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Run all CRM dedupe passes (companies, persons, associations, titles)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := onlyoffice.CleanupCRM(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group deals by position only (strip ' @ Company')")
return cmd
}
func dedupeRunE(fn func(cmd *cobra.Command, c *onlyoffice.Client) error) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
return fn(cmd, c)
}
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var crmTasksCmd = &cobra.Command{
Use: "crm-tasks",
Aliases: []string{"crm-task"},
Short: "CRM tasks (standalone, not project tasks)",
}
func init() {
rootCmd.AddCommand(crmTasksCmd)
crmTasksCmd.AddCommand(crmTasksListCmd())
crmTasksCmd.AddCommand(crmTasksCreateCmd())
crmTasksCmd.AddCommand(crmTasksDeleteCmd())
crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
}
func crmTasksListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List CRM tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListCRMTasks(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
}
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 50, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func crmTasksCreateCmd() *cobra.Command {
var deadline, desc, entityType string
var categoryID, contactID, entityID int
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a CRM task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.CreateCRMTask(cmd.Context(), args[0], deadline, categoryID, contactID, entityType, entityID, desc)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)")
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
cmd.Flags().IntVar(&entityID, "entity-id", 0, "parent entity id")
cmd.Flags().StringVar(&desc, "description", "", "description")
return cmd
}
func crmTasksDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]",
Aliases: []string{"rm"},
Short: "Delete CRM tasks",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
if _, err := strconv.Atoi(id); err != nil {
return fmt.Errorf("task id %q must be integer: %w", id, err)
}
out, err := c.DeleteCRMTask(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func crmTasksCategoriesCmd() *cobra.Command {
return &cobra.Command{
Use: "categories",
Short: "List CRM task categories",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListTaskCategories(cmd.Context())
if err != nil {
return err
}
printTable([]string{"id", "title", "sortOrder", "imagePath"}, out)
return nil
},
}
}
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"strconv"
"strings"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var mailsCmd = &cobra.Command{
Use: "mails",
Aliases: []string{"mail"},
Short: "OnlyOffice Workspace mail — list, read, delete",
}
func init() {
rootCmd.AddCommand(mailsCmd)
mailsCmd.AddCommand(mailsAccountsCmd())
mailsCmd.AddCommand(mailsFoldersCmd())
mailsCmd.AddCommand(mailsListCmd())
mailsCmd.AddCommand(mailsGetCmd())
mailsCmd.AddCommand(mailsDeleteCmd())
}
func mailsAccountsCmd() *cobra.Command {
return &cobra.Command{
Use: "accounts",
Short: "List mailboxes linked to your OnlyOffice account",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
accounts, err := c.ListMailAccounts(cmd.Context())
if err != nil {
return err
}
if outputFormat == "json" {
printObject(accounts)
return nil
}
printTable([]string{"mailboxId", "email", "enabled", "isDefault"}, onlyoffice.MailAccountsAsTableRows(accounts))
return nil
},
}
}
func mailsFoldersCmd() *cobra.Command {
return &cobra.Command{
Use: "folders",
Short: "List mail folders (inbox, sent, trash, …)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
folders, err := c.ListMailFolders(cmd.Context())
if err != nil {
return err
}
if outputFormat == "json" {
printObject(folders)
return nil
}
printTable([]string{"id", "unread", "total_count", "time_modified"}, onlyoffice.MailFoldersAsTableRows(folders))
return nil
},
}
}
func mailsListCmd() *cobra.Command {
var folder string
var limit, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List messages in a folder (default inbox)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
folderID, err := onlyoffice.ResolveMailFolder(folder)
if err != nil {
return err
}
msgs, err := c.ListMailMessages(cmd.Context(), onlyoffice.MailMessagesFilter{
Folder: folderID,
Count: limit,
StartIndex: offset,
})
if err != nil {
return err
}
if outputFormat == "json" {
printObject(msgs)
return nil
}
printTable([]string{"id", "subject", "fromName", "fromAddress", "date", "folder", "size", "isNew"}, onlyoffice.MailMessagesAsTableRows(msgs))
return nil
},
}
cmd.Flags().StringVarP(&folder, "folder", "f", "inbox", "folder name (inbox|sent|drafts|trash|spam) or numeric id")
cmd.Flags().IntVar(&limit, "limit", 50, "max messages to return (paginates past API page size of 25)")
cmd.Flags().IntVar(&offset, "offset", 0, "skip this many messages before listing")
return cmd
}
func mailsGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get MESSAGE_ID",
Short: "Read one message by numeric id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetMailMessage(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func mailsDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete ID [ID...]",
Aliases: []string{"rm"},
Short: "Remove messages from the mailbox (moves to trash or deletes permanently per server rules)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ids := make([]int, 0, len(args))
for _, a := range args {
id, err := strconv.Atoi(strings.TrimSpace(a))
if err != nil {
return err
}
ids = append(ids, id)
}
out, err := c.RemoveMailMessages(cmd.Context(), ids...)
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
+36
View File
@@ -0,0 +1,36 @@
// Command oo is a thin CLI over the github.com/eslider/go-onlyoffice library.
//
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
//
// oo calendar list | events | add | delete
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add | dedupe-info
// oo persons list | create | delete | dedupe
// oo companies list | create | delete | dedupe | dedupe-persons
// oo opportunities list | get | create | delete | stages | member-add | dedupe | dedupe-members | fix-titles
// oo cases list | create | delete | member-add
// oo crm-tasks list | create | delete | categories
// oo crm cleanup
// oo mails accounts | folders | list | get | delete
// oo applications sync
//
// Every list supports `--output/-o json|table` (table is the default).
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/oo@latest
package main
import (
"fmt"
"os"
)
func main() {
if err := execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+229
View File
@@ -0,0 +1,229 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var opportunitiesCmd = &cobra.Command{
Use: "opportunities",
Aliases: []string{"deals", "deal"},
Short: "CRM opportunities (a.k.a. deals)",
}
func init() {
rootCmd.AddCommand(opportunitiesCmd)
opportunitiesCmd.AddCommand(oppListCmd())
opportunitiesCmd.AddCommand(oppGetCmd())
opportunitiesCmd.AddCommand(oppCreateCmd())
opportunitiesCmd.AddCommand(oppDeleteCmd())
opportunitiesCmd.AddCommand(oppStagesCmd())
opportunitiesCmd.AddCommand(oppMemberAddCmd())
opportunitiesCmd.AddCommand(oppDedupeCmd())
opportunitiesCmd.AddCommand(oppDedupeMembersCmd())
opportunitiesCmd.AddCommand(oppFixTitlesCmd())
}
func oppListCmd() *cobra.Command {
var count, offset int
cmd := &cobra.Command{
Use: "list",
Short: "List opportunities",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, total, err := c.ListOpportunities(cmd.Context(), count, offset)
if err != nil {
return err
}
if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list))
// Flatten nested bidCurrency {abbreviation, ...} for readability.
for _, row := range list {
if cur, ok := row["bidCurrency"].(map[string]any); ok {
row["bidCurrency"] = cur["abbreviation"]
}
}
}
printTable([]string{"id", "title", "bidValue", "bidCurrency", "stageName"}, list)
return nil
},
}
cmd.Flags().IntVar(&count, "count", 100, "")
cmd.Flags().IntVar(&offset, "offset", 0, "")
return cmd
}
func oppGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get OPPORTUNITY_ID",
Short: "Show an opportunity by id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetOpportunity(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func oppCreateCmd() *cobra.Command {
var stage int
var bid float64
var currency, responsible, desc string
var contacts []string
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create an opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, responsible, currency, desc, bid)
if err != nil {
return err
}
did := strconv.Itoa(int(flexIDFloat(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printObject(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "pipeline stage id")
cmd.Flags().Float64Var(&bid, "bid", 0, "bid value")
cmd.Flags().StringVar(&currency, "currency", "EUR", "bid currency")
cmd.Flags().StringVar(&responsible, "responsible", "", "responsible user id")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id to add as member (repeatable)")
return cmd
}
func oppDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete OPPORTUNITY_ID [OPPORTUNITY_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more opportunities",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteOpportunity(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
func oppStagesCmd() *cobra.Command {
return &cobra.Command{
Use: "stages",
Short: "List pipeline stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printTable([]string{"id", "title", "sortOrder", "color"}, out)
return nil
},
}
}
func oppMemberAddCmd() *cobra.Command {
return &cobra.Command{
Use: "member-add OPPORTUNITY_ID CONTACT_ID",
Short: "Attach a contact to an opportunity",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.AddOpportunityMember(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func oppDedupeCmd() *cobra.Command {
var ignoreCompanySuffix bool
cmd := &cobra.Command{
Use: "dedupe",
Short: "Merge duplicate opportunities by title",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunities(cmd.Context(), c, ignoreCompanySuffix)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
cmd.Flags().BoolVar(&ignoreCompanySuffix, "ignore-company-suffix", false, "group by position only")
return cmd
}
func oppDedupeMembersCmd() *cobra.Command {
return &cobra.Command{
Use: "dedupe-members",
Short: "Remove duplicate contacts on each deal (by id and display name)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.DedupeOpportunityMembers(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
func oppFixTitlesCmd() *cobra.Command {
return &cobra.Command{
Use: "fix-titles",
Short: "Fix malformed deal titles (leading @, spacing)",
RunE: dedupeRunE(func(cmd *cobra.Command, c *onlyoffice.Client) error {
res, err := onlyoffice.FixOpportunityTitles(cmd.Context(), c)
if err != nil {
return err
}
printObject(res)
return nil
}),
}
}
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
var projectsCmd = &cobra.Command{
Use: "projects",
Aliases: []string{"prj"},
Short: "OnlyOffice Projects",
}
func init() {
rootCmd.AddCommand(projectsCmd)
projectsCmd.AddCommand(prjListCmd())
projectsCmd.AddCommand(prjGetCmd())
projectsCmd.AddCommand(prjMilestonesCmd())
projectsCmd.AddCommand(prjCreateCmd())
projectsCmd.AddCommand(prjUpdateCmd())
projectsCmd.AddCommand(prjDeleteCmd())
}
func prjListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all projects",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.GetProjects()
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(list))
for _, p := range list {
row := map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
"status": derefInt(p.Status),
}
if p.TaskCount != nil {
row["tasks"] = *p.TaskCount
}
if p.IsPrivate != nil {
row["private"] = *p.IsPrivate
}
rows = append(rows, row)
}
printTable([]string{"id", "title", "status", "tasks", "private"}, rows)
return nil
},
}
}
func prjGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get [PROJECT_ID]",
Short: "Show a single project (default: $OO_PROJECT_ID)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id := ""
if len(args) == 1 {
id = args[0]
}
out, err := c.GetProjectByID(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func prjMilestonesCmd() *cobra.Command {
return &cobra.Command{
Use: "milestones PROJECT_ID",
Short: "List milestones of a project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
ms, err := c.GetProjectMilestones(&onlyoffice.Project{ID: &pid})
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(ms))
for _, m := range ms {
rows = append(rows, map[string]any{
"id": derefInt64(m.ID),
"title": derefString(m.Title),
"status": derefInt64(m.Status),
})
}
printTable([]string{"id", "title", "status"}, rows)
return nil
},
}
}
func prjCreateCmd() *cobra.Command {
var desc, resp string
cmd := &cobra.Command{
Use: "create TITLE",
Short: "Create a new project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
p, err := c.CreateProject(onlyoffice.NewProjectRequest{
Title: args[0],
Description: desc,
ResponsibleID: resp,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
})
return nil
},
}
cmd.Flags().StringVar(&desc, "description", "", "project description")
cmd.Flags().StringVar(&resp, "responsible", "", "responsible user id (default: self)")
return cmd
}
func prjUpdateCmd() *cobra.Command {
var title, desc, resp string
cmd := &cobra.Command{
Use: "update PROJECT_ID",
Short: "Update project fields (only non-empty)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id must be integer: %w", err)
}
p, err := c.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: desc,
ResponsibleID: resp,
})
if err != nil {
return err
}
printObject(map[string]any{
"id": derefInt(p.ID),
"title": p.String(),
})
return nil
},
}
cmd.Flags().StringVar(&title, "title", "", "new title")
cmd.Flags().StringVar(&desc, "description", "", "new description")
cmd.Flags().StringVar(&resp, "responsible", "", "new responsible user id")
return cmd
}
func prjDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete PROJECT_ID [PROJECT_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more projects",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, raw := range args {
id, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("project id %q must be integer: %w", raw, err)
}
p, err := c.DeleteProject(id)
if err != nil {
return err
}
printObject(map[string]any{"id": derefInt(p.ID), "title": p.String()})
}
return nil
},
}
}
func derefString(p *string) string {
if p == nil {
return ""
}
return *p
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}
func derefInt64(p *int64) int64 {
if p == nil {
return 0
}
return *p
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"fmt"
"os"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
func init() {
projectsCmd.AddCommand(projectFilesCmd())
}
func projectFilesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "files",
Short: "Project Documents folder: list, upload, download, rename, delete",
}
cmd.AddCommand(prjFilesListCmd())
cmd.AddCommand(prjFilesUploadCmd())
cmd.AddCommand(prjFilesDownloadCmd())
cmd.AddCommand(prjFilesRenameCmd())
cmd.AddCommand(prjFilesDeleteCmd())
return cmd
}
func prjFilesListCmd() *cobra.Command {
var showFolders bool
cmd := &cobra.Command{
Use: "list PROJECT_ID",
Short: "List files (and optionally folders) attached to the project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pf, err := c.GetProjectFiles(cmd.Context(), args[0])
if err != nil {
return err
}
if showFolders && len(pf.Folders) > 0 {
frows := make([]map[string]any, 0, len(pf.Folders))
for _, f := range pf.Folders {
if f == nil {
continue
}
frows = append(frows, map[string]any{
"id": folderIDStr(f),
"title": derefString(f.Title),
"filesCount": derefInt(f.FilesCount),
"foldersCount": derefInt(f.FoldersCount),
})
}
if outputFormat == "table" {
fmt.Println("folders:")
}
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
}
rows := fileEntryRows(pf.Files)
if outputFormat == "table" {
fmt.Println("files:")
}
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, rows)
return nil
},
}
cmd.Flags().BoolVar(&showFolders, "folders", false, "also print project subfolders")
return cmd
}
func prjFilesUploadCmd() *cobra.Command {
return &cobra.Command{
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload file(s) into the project's Documents folder",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid := args[0]
for _, p := range args[1:] {
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
}
return nil
},
}
}
func prjFilesDownloadCmd() *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "download FILE_ID",
Short: "Download file bytes via viewUrl (default path: ./<title>)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
f, err := c.GetFile(cmd.Context(), args[0])
if err != nil {
return err
}
path := to
if path == "" {
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
n, err := c.DownloadFile(cmd.Context(), args[0], out)
if err != nil {
_ = os.Remove(path)
return err
}
if outputFormat == "json" {
printObject(map[string]any{"path": path, "bytes": n})
return nil
}
fmt.Printf("downloaded: %s (%d bytes)\n", path, n)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
return cmd
}
func prjFilesRenameCmd() *cobra.Command {
return &cobra.Command{
Use: "rename FILE_ID NEW_TITLE",
Short: "Rename a file (include extension in NEW_TITLE)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
return nil
},
}
}
func prjFilesDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete FILE_ID [FILE_ID...]",
Aliases: []string{"rm"},
Short: "Permanently delete file(s) from Documents",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ids := make([]int, 0, len(args))
for _, s := range args {
id, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("file id %q: %w", s, err)
}
ids = append(ids, id)
}
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
return err
}
printObject(map[string]any{"deleted": ids})
return nil
},
}
}
func fileEntryRows(files []*onlyoffice.FileEntry) []map[string]any {
rows := make([]map[string]any, 0, len(files))
for _, f := range files {
if f == nil {
continue
}
rows = append(rows, fileEntryToMap(f))
}
return rows
}
func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
m := map[string]any{
"id": fileIDStr(f),
"title": onlyoffice.FileEntryTitle(f),
"fileExst": derefString(f.FileExst),
"contentLength": derefString(f.ContentLength),
}
if f.Updated != nil {
m["updated"] = f.Updated.Format(time.RFC3339)
}
return m
}
func fileIDStr(f *onlyoffice.FileEntry) string {
if f == nil || f.ID == nil {
return ""
}
return f.ID.String()
}
func folderIDStr(f *onlyoffice.FolderEntry) string {
if f == nil || f.ID == nil {
return ""
}
return f.ID.String()
}
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var tasksCmd = &cobra.Command{
Use: "tasks",
Aliases: []string{"task"},
Short: "Project tasks and subtasks",
}
func init() {
rootCmd.AddCommand(tasksCmd)
tasksCmd.AddCommand(taskListCmd())
tasksCmd.AddCommand(taskGetCmd())
tasksCmd.AddCommand(taskCreateCmd())
tasksCmd.AddCommand(taskUpdateCmd())
tasksCmd.AddCommand(taskDeleteCmd())
tasksCmd.AddCommand(subtaskCmd())
}
func taskListCmd() *cobra.Command {
var project, status string
var all, verbose bool
cmd := &cobra.Command{
Use: "list",
Short: "List project tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
var tasks []map[string]any
if all {
tasks, err = c.ListAllTasks(cmd.Context(), status)
} else {
tasks, err = c.ListTasks(cmd.Context(), project, status)
}
if err != nil {
return err
}
if verbose {
for i, t := range tasks {
d, err := c.GetTaskByID(cmd.Context(), idString(t, "id"))
if err == nil {
tasks[i] = d
}
}
}
printTable([]string{"id", "title", "status", "priority", "responsible"}, tasks)
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "include subtasks & full details")
return cmd
}
func taskGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get TASK_ID",
Short: "Show a single task (incl. subtasks)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.GetTaskByID(cmd.Context(), args[0])
if err != nil {
return err
}
printObject(out)
return nil
},
}
}
func taskCreateCmd() *cobra.Command {
var project, desc, deadline, prio string
cmd := &cobra.Command{
Use: "create TITLE",
Aliases: []string{"add"},
Short: "Create a project task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
p := 0
switch prio {
case "high":
p = 1
case "low":
p = -1
}
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd
}
func taskUpdateCmd() *cobra.Command {
var status string
cmd := &cobra.Command{
Use: "update TASK_ID",
Short: "Update task status",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if status == "" {
return fmt.Errorf("--status is required (open|closed)")
}
c, err := newOO(cmd)
if err != nil {
return err
}
out, err := c.UpdateTaskStatus(cmd.Context(), args[0], status)
if err != nil {
return err
}
printObject(out)
return nil
},
}
cmd.Flags().StringVarP(&status, "status", "s", "", "new status: open|closed")
return cmd
}
func taskDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]",
Aliases: []string{"rm"},
Short: "Delete one or more tasks",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printObject(out)
}
return nil
},
}
}
// subtaskCmd exposes `oo tasks subtask {add}` — keeps things tidy without a
// dedicated subtasks top-level command.
func subtaskCmd() *cobra.Command {
sub := &cobra.Command{
Use: "subtask",
Short: "Subtasks of a parent task",
}
sub.AddCommand(&cobra.Command{
Use: "add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add one or more subtasks",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
parent := args[0]
for _, title := range args[1:] {
out, err := c.AddSubtask(cmd.Context(), parent, title)
if err != nil {
return err
}
printObject(out)
}
return nil
},
})
return sub
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
func init() {
tasksCmd.AddCommand(taskFilesCmd())
}
func taskFilesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "files",
Short: "Task attachments: list, upload (project folder + attach), detach",
}
cmd.AddCommand(taskFilesListCmd())
cmd.AddCommand(taskFilesUploadCmd())
cmd.AddCommand(taskFilesDetachCmd())
return cmd
}
func taskFilesListCmd() *cobra.Command {
return &cobra.Command{
Use: "list TASK_ID",
Short: "List files attached to a task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.GetTaskFiles(cmd.Context(), args[0])
if err != nil {
return err
}
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, fileEntryRows(list))
return nil
},
}
}
func taskFilesUploadCmd() *cobra.Command {
return &cobra.Command{
Use: "upload TASK_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload into the task's project folder and attach each file to the task",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
tid := args[0]
for _, p := range args[1:] {
entry, err := c.UploadTaskFile(cmd.Context(), tid, p)
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
}
return nil
},
}
}
func taskFilesDetachCmd() *cobra.Command {
return &cobra.Command{
Use: "detach TASK_ID FILE_ID [FILE_ID...]",
Short: "Detach file(s) from the task (files remain in Documents)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
tid := args[0]
for _, fid := range args[1:] {
if _, err := strconv.Atoi(fid); err != nil {
return fmt.Errorf("file id %q: %w", fid, err)
}
if err := c.DetachTaskFile(cmd.Context(), tid, fid); err != nil {
return err
}
printObject(map[string]any{"taskId": tid, "detachedFileId": fid})
}
return nil
},
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"github.com/spf13/cobra"
)
var usersCmd = &cobra.Command{
Use: "users",
Short: "Portal / workspace users",
}
func init() {
rootCmd.AddCommand(usersCmd)
usersCmd.AddCommand(usersListCmd())
usersCmd.AddCommand(usersSelfCmd())
rootCmd.AddCommand(whoamiCmd())
}
func usersListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List portal users",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
users, err := c.GetUsers()
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(users))
for _, u := range users {
rows = append(rows, map[string]any{
"id": derefString(u.ID),
"userName": derefString(u.UserName),
"displayName": derefString(u.DisplayName),
"email": derefString(u.Email),
"isAdmin": derefBool(u.IsAdmin),
"status": derefInt(u.Status),
})
}
printTable([]string{"id", "userName", "displayName", "email", "isAdmin", "status"}, rows)
return nil
},
}
}
func usersSelfCmd() *cobra.Command {
return &cobra.Command{
Use: "self",
Short: "Show current user id (people/@self)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
printObject(map[string]any{"id": id})
return nil
},
}
}
// whoamiCmd is a convenience shortcut at the root level.
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Alias for `oo users self`",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
id, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
printObject(map[string]any{"id": id})
return nil
},
}
}
func derefBool(p *bool) bool {
if p == nil {
return false
}
return *p
}
+8
View File
@@ -0,0 +1,8 @@
package main
// Build metadata injected at release time via -ldflags (GoReleaser).
var (
version = "dev"
commit = "none"
date = "unknown"
)
+580
View File
@@ -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
View File
@@ -0,0 +1,272 @@
package onlyoffice
import (
"context"
"fmt"
"strconv"
)
// DedupeResult summarizes a cleanup pass.
type DedupeResult struct {
Groups int `json:"groups"`
Merged int `json:"merged"`
Deleted int `json:"deleted"`
Renamed int `json:"renamed"`
Removed int `json:"removed"`
Errors []string `json:"errors,omitempty"`
}
func (r *DedupeResult) addErr(err error) {
if err != nil {
r.Errors = append(r.Errors, err.Error())
}
}
type crmDedupeClient interface {
ListAllContacts(ctx context.Context) ([]map[string]any, error)
ListAllOpportunities(ctx context.Context) ([]map[string]any, error)
GetContact(ctx context.Context, contactID string) (map[string]any, error)
GetOpportunity(ctx context.Context, id string) (map[string]any, error)
MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error)
DeleteOpportunity(ctx context.Context, id string) (map[string]any, error)
DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error)
ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error)
AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error)
UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error)
}
func executeMergePlans(ctx context.Context, client crmDedupeClient, plans []MergePlan, res *DedupeResult) {
for _, plan := range plans {
res.Groups++
for _, sec := range plan.Secondary {
_, err := client.MergeContacts(ctx, strconv.FormatInt(plan.Primary, 10), strconv.FormatInt(sec, 10))
if err != nil {
res.addErr(fmt.Errorf("merge %d into %d: %w", sec, plan.Primary, err))
continue
}
res.Merged++
}
}
}
// DedupeCompanies merges duplicate company contacts by normalized name.
func DedupeCompanies(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
plans := BuildMergePlans(GroupCompaniesByName(items))
executeMergePlans(ctx, client, plans, &res)
return res, nil
}
// DedupePersons merges duplicate person contacts by normalized first+last.
func DedupePersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
plans := BuildMergePlans(GroupPersonsByKey(items))
executeMergePlans(ctx, client, plans, &res)
return res, nil
}
// DedupeCompanyPersons merges same-name persons within each company.
func DedupeCompanyPersons(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
grouped := GroupCompanyPersons(items)
for companyID, byName := range grouped {
plans := BuildMergePlans(byName)
if len(plans) == 0 {
continue
}
_ = companyID
executeMergePlans(ctx, client, plans, &res)
}
return res, nil
}
// DedupeContactInfo removes duplicate email/phone/etc rows on all contacts.
func DedupeContactInfo(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllContacts(ctx)
if err != nil {
return res, err
}
for _, row := range items {
cid := strconv.FormatInt(rowID(row), 10)
contact, err := client.GetContact(ctx, cid)
if err != nil {
res.addErr(err)
continue
}
rows := ContactInfoRows(contact)
for _, dataID := range GroupContactInfoRows(rows) {
_, err := client.DeleteContactInfo(ctx, cid, strconv.FormatInt(dataID, 10))
if err != nil {
res.addErr(err)
continue
}
res.Removed++
}
}
return res, nil
}
// DedupeOpportunities merges duplicate deals by title; relinks members first.
func DedupeOpportunities(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
groups := GroupOpportunitiesByTitle(items, ignoreCompanySuffix)
for _, rows := range groups {
if len(rows) < 2 {
continue
}
res.Groups++
ids := make([]int64, len(rows))
for i, row := range rows {
ids[i] = rowID(row)
}
primary := PickCanonicalID(ids)
primaryID := strconv.FormatInt(primary, 10)
for _, row := range rows {
sec := rowID(row)
if sec == primary {
continue
}
secID := strconv.FormatInt(sec, 10)
opp, err := client.GetOpportunity(ctx, secID)
if err != nil {
res.addErr(err)
continue
}
for _, m := range OpportunityMembers(opp) {
mid := strconv.FormatInt(rowID(m), 10)
_, _ = client.AddOpportunityMember(ctx, primaryID, mid)
}
if _, err := client.DeleteOpportunity(ctx, secID); err != nil {
res.addErr(err)
continue
}
res.Deleted++
}
}
return res, nil
}
// DedupeOpportunityMembers removes duplicate members on each deal (by id and displayName).
func DedupeOpportunityMembers(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
for _, row := range items {
oppID := strconv.FormatInt(rowID(row), 10)
opp, err := client.GetOpportunity(ctx, oppID)
if err != nil {
res.addErr(err)
continue
}
members := OpportunityMembers(opp)
if len(members) == 0 {
continue
}
var ids []int64
for _, m := range members {
ids = append(ids, rowID(m))
}
remove := append(DedupeMemberIDs(ids), DedupeMembersByDisplayName(members)...)
seen := make(map[int64]bool)
for _, contactID := range remove {
if contactID == 0 || seen[contactID] {
continue
}
seen[contactID] = true
if _, err := client.RemoveOpportunityMember(ctx, oppID, strconv.FormatInt(contactID, 10)); err != nil {
res.addErr(err)
continue
}
res.Removed++
}
}
return res, nil
}
// FixOpportunityTitles renames deals with malformed titles.
func FixOpportunityTitles(ctx context.Context, client crmDedupeClient) (DedupeResult, error) {
var res DedupeResult
items, err := client.ListAllOpportunities(ctx)
if err != nil {
return res, err
}
for _, row := range items {
old := fmt.Sprint(row["title"])
newTitle := FixDealTitle(old)
if newTitle == old || newTitle == "" {
continue
}
oppID := strconv.FormatInt(rowID(row), 10)
if _, err := client.UpdateOpportunityTitle(ctx, oppID, newTitle); err != nil {
res.addErr(err)
continue
}
res.Renamed++
}
return res, nil
}
// CleanupCRM runs all dedupe passes in dependency order.
func CleanupCRM(ctx context.Context, client crmDedupeClient, ignoreCompanySuffix bool) (map[string]DedupeResult, error) {
out := make(map[string]DedupeResult)
steps := []struct {
name string
fn func(context.Context, crmDedupeClient) (DedupeResult, error)
}{
{"companies", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeCompanies(ctx, c)
}},
{"persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupePersons(ctx, c)
}},
{"company-persons", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeCompanyPersons(ctx, c)
}},
{"contact-info", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeContactInfo(ctx, c)
}},
{"opportunity-members", func(ctx context.Context, c crmDedupeClient) (DedupeResult, error) {
return DedupeOpportunityMembers(ctx, c)
}},
}
for _, step := range steps {
r, err := step.fn(ctx, client)
out[step.name] = r
if err != nil {
return out, err
}
}
r, err := DedupeOpportunities(ctx, client, ignoreCompanySuffix)
out["opportunities"] = r
if err != nil {
return out, err
}
r, err = FixOpportunityTitles(ctx, client)
out["fix-titles"] = r
if err != nil {
return out, err
}
return out, nil
}
var _ crmDedupeClient = (*Client)(nil)
+243
View File
@@ -0,0 +1,243 @@
package onlyoffice
import (
"fmt"
"sort"
"strings"
)
// PickCanonicalID returns the lowest non-zero id, or 0 when empty.
func PickCanonicalID(ids []int64) int64 {
if len(ids) == 0 {
return 0
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids[0]
}
func rowID(m map[string]any) int64 {
return flexInt(m["id"])
}
func rowDisplayName(m map[string]any) string {
if v := strings.TrimSpace(fmt.Sprint(m["displayName"])); v != "" && v != "<nil>" {
return v
}
if v := strings.TrimSpace(fmt.Sprint(m["companyName"])); v != "" && v != "<nil>" {
return v
}
first := strings.TrimSpace(fmt.Sprint(m["firstName"]))
last := strings.TrimSpace(fmt.Sprint(m["lastName"]))
return strings.TrimSpace(first + " " + last)
}
// GroupCompaniesByName buckets company contacts by normalized display name.
func GroupCompaniesByName(items []map[string]any) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
if !isCompany(row) {
continue
}
key := CompanyGroupingKey(rowDisplayName(row))
if key == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// GroupPersonsByKey buckets person contacts by normalized first+last.
func GroupPersonsByKey(items []map[string]any) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
if isCompany(row) {
continue
}
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
if strings.TrimSpace(key) == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// DealTitleKey returns the grouping key for an opportunity title.
func DealTitleKey(title string, ignoreCompanySuffix bool) string {
title = strings.TrimSpace(title)
if ignoreCompanySuffix {
return NormalizeOpportunityTitle(StripCompanySuffix(title))
}
if i := strings.LastIndex(title, " @ "); i >= 0 {
pos := strings.TrimSpace(title[:i])
co := StripSloganSuffix(strings.TrimSpace(title[i+len(" @ "):]))
if pos == "" && co != "" {
return NormalizeCompanyName(co)
}
if pos != "" && co != "" {
return collapseKey(pos + " @ " + co)
}
}
return NormalizeCompanyName(StripSloganSuffix(title))
}
// GroupOpportunitiesByTitle buckets deals by title key.
func GroupOpportunitiesByTitle(items []map[string]any, ignoreCompanySuffix bool) map[string][]map[string]any {
out := make(map[string][]map[string]any)
for _, row := range items {
title := fmt.Sprint(row["title"])
key := DealTitleKey(title, ignoreCompanySuffix)
if key == "" {
continue
}
out[key] = append(out[key], row)
}
return out
}
// MergePlan lists secondary ids to merge into primary.
type MergePlan struct {
Primary int64
Secondary []int64
}
// BuildMergePlans creates merge plans from duplicate groups (lowest id wins).
func BuildMergePlans(groups map[string][]map[string]any) []MergePlan {
var plans []MergePlan
for _, rows := range groups {
if len(rows) < 2 {
continue
}
ids := make([]int64, len(rows))
for i, row := range rows {
ids[i] = rowID(row)
}
primary := PickCanonicalID(ids)
var secondary []int64
for _, id := range ids {
if id != primary {
secondary = append(secondary, id)
}
}
plans = append(plans, MergePlan{Primary: primary, Secondary: secondary})
}
return plans
}
// DedupeMemberIDs returns duplicate member ids to remove (keep first occurrence).
func DedupeMemberIDs(ids []int64) []int64 {
seen := make(map[int64]int)
var remove []int64
for _, id := range ids {
seen[id]++
if seen[id] > 1 {
remove = append(remove, id)
}
}
return remove
}
// DedupeMembersByDisplayName returns member ids to remove when the same
// displayName appears with different ids (keep lowest id per name).
func DedupeMembersByDisplayName(members []map[string]any) []int64 {
type slot struct {
id int64
}
byName := make(map[string][]slot)
for _, m := range members {
key := MemberDisplayKey(rowDisplayName(m))
if key == "" {
continue
}
byName[key] = append(byName[key], slot{id: rowID(m)})
}
var remove []int64
for _, slots := range byName {
if len(slots) < 2 {
continue
}
ids := make([]int64, len(slots))
for i, s := range slots {
ids[i] = s.id
}
keep := PickCanonicalID(ids)
for _, s := range slots {
if s.id != keep {
remove = append(remove, s.id)
}
}
}
return remove
}
// GroupContactInfoRows returns info row ids to delete (duplicates by type+value).
func GroupContactInfoRows(rows []map[string]any) []int64 {
type rowSlot struct {
id int64
isPrimary bool
}
byKey := make(map[string][]rowSlot)
for _, row := range rows {
infoType := fmt.Sprint(row["infoType"])
value := fmt.Sprint(row["data"])
if value == "" || value == "<nil>" {
value = fmt.Sprint(row["value"])
}
key := ContactInfoKey(infoType, value)
if key == "|" || value == "" || value == "<nil>" {
continue
}
primary, _ := row["isPrimary"].(bool)
byKey[key] = append(byKey[key], rowSlot{id: rowID(row), isPrimary: primary})
}
var remove []int64
for _, slots := range byKey {
if len(slots) < 2 {
continue
}
keep := int64(0)
for _, s := range slots {
if s.isPrimary {
keep = s.id
break
}
}
if keep == 0 {
ids := make([]int64, len(slots))
for i, s := range slots {
ids[i] = s.id
}
keep = PickCanonicalID(ids)
}
for _, s := range slots {
if s.id != keep {
remove = append(remove, s.id)
}
}
}
return remove
}
// GroupCompanyPersons groups persons by company id and person name key.
func GroupCompanyPersons(persons []map[string]any) map[int64]map[string][]map[string]any {
out := make(map[int64]map[string][]map[string]any)
for _, row := range persons {
if isCompany(row) {
continue
}
cid := flexInt(row["companyId"])
if cid == 0 {
continue
}
key := NormalizePersonKey(fmt.Sprint(row["firstName"]), fmt.Sprint(row["lastName"]))
if strings.TrimSpace(key) == "" {
continue
}
if out[cid] == nil {
out[cid] = make(map[string][]map[string]any)
}
out[cid][key] = append(out[cid][key], row)
}
return out
}
+100
View File
@@ -0,0 +1,100 @@
package onlyoffice
import (
"reflect"
"testing"
)
func TestPickCanonicalID(t *testing.T) {
if got := PickCanonicalID([]int64{908, 857, 900}); got != 857 {
t.Fatalf("got %d", got)
}
if got := PickCanonicalID(nil); got != 0 {
t.Fatalf("got %d", got)
}
}
func TestGroupCompaniesByName(t *testing.T) {
items := []map[string]any{
{"id": float64(857), "displayName": "contoso", "isCompany": true},
{"id": float64(908), "displayName": "contoso", "isCompany": true},
{"id": float64(1), "displayName": "Acme", "isCompany": true},
}
groups := GroupCompaniesByName(items)
if len(groups) != 2 {
t.Fatalf("groups: %d", len(groups))
}
key := CompanyGroupingKey("contoso")
if len(groups[key]) != 2 {
t.Fatalf("contoso group: %d", len(groups[key]))
}
}
func TestDedupeMembersByDisplayName(t *testing.T) {
members := []map[string]any{
{"id": float64(857), "displayName": "contoso"},
{"id": float64(908), "displayName": "contoso"},
{"id": float64(10), "displayName": "Acme"},
}
remove := DedupeMembersByDisplayName(members)
want := []int64{908}
if !reflect.DeepEqual(remove, want) {
t.Fatalf("remove %v want %v", remove, want)
}
}
func TestDedupeMemberIDs(t *testing.T) {
remove := DedupeMemberIDs([]int64{1, 2, 2, 3, 1})
if !reflect.DeepEqual(remove, []int64{2, 1}) {
t.Fatalf("got %v", remove)
}
}
func TestGroupContactInfoRows(t *testing.T) {
rows := []map[string]any{
{"id": float64(1), "infoType": "Email", "data": "a@b.com", "isPrimary": false},
{"id": float64(2), "infoType": "Email", "data": "a@b.com", "isPrimary": true},
}
remove := GroupContactInfoRows(rows)
if len(remove) != 1 || remove[0] != 1 {
t.Fatalf("remove %v", remove)
}
}
func TestGroupCompaniesBySlogan(t *testing.T) {
items := []map[string]any{
{"id": float64(1), "displayName": "Affirm", "isCompany": true},
{"id": float64(2), "displayName": "Affirm — Fraud Engineering", "isCompany": true},
}
groups := GroupCompaniesByName(items)
if len(groups) != 1 {
t.Fatalf("groups: %d", len(groups))
}
key := CompanyGroupingKey("Affirm")
if len(groups[key]) != 2 {
t.Fatalf("affirm group: %d", len(groups[key]))
}
}
func TestDedupeMembersBySloganDisplayName(t *testing.T) {
members := []map[string]any{
{"id": float64(1), "displayName": "Affirm"},
{"id": float64(2), "displayName": "Affirm — Fraud Engineering"},
}
remove := DedupeMembersByDisplayName(members)
if !reflect.DeepEqual(remove, []int64{2}) {
t.Fatalf("remove %v", remove)
}
}
func TestDealTitleKey(t *testing.T) {
if got := DealTitleKey("Dev @ Acme", false); got != collapseKey("Dev @ Acme") {
t.Fatalf("got %q", got)
}
if got := DealTitleKey("Dev @ Acme", true); got != NormalizeOpportunityTitle("Dev") {
t.Fatalf("got %q", got)
}
if got := DealTitleKey("Dev @ Affirm — Fraud Engineering", false); got != DealTitleKey("Dev @ Affirm", false) {
t.Fatalf("slogan keys differ: %q vs %q", got, DealTitleKey("Dev @ Affirm", false))
}
}
+133
View File
@@ -0,0 +1,133 @@
package onlyoffice
import (
"context"
"reflect"
"testing"
)
type fakeDedupeClient struct {
contacts []map[string]any
opportunities []map[string]any
contactByID map[string]map[string]any
oppByID map[string]map[string]any
merged [][2]string
deletedOpp []string
deletedInfo [][2]string
removedMember [][2]string
renamed map[string]string
}
func (f *fakeDedupeClient) ListAllContacts(ctx context.Context) ([]map[string]any, error) {
return f.contacts, nil
}
func (f *fakeDedupeClient) ListAllOpportunities(ctx context.Context) ([]map[string]any, error) {
return f.opportunities, nil
}
func (f *fakeDedupeClient) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
return f.contactByID[contactID], nil
}
func (f *fakeDedupeClient) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
return f.oppByID[id], nil
}
func (f *fakeDedupeClient) MergeContacts(ctx context.Context, primaryID, secondaryID string) (map[string]any, error) {
f.merged = append(f.merged, [2]string{primaryID, secondaryID})
return map[string]any{"id": primaryID}, nil
}
func (f *fakeDedupeClient) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
f.deletedOpp = append(f.deletedOpp, id)
return map[string]any{}, nil
}
func (f *fakeDedupeClient) DeleteContactInfo(ctx context.Context, contactID, dataID string) (map[string]any, error) {
f.deletedInfo = append(f.deletedInfo, [2]string{contactID, dataID})
return map[string]any{}, nil
}
func (f *fakeDedupeClient) ListCompanyPersons(ctx context.Context, companyID string) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeDedupeClient) AddOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
return map[string]any{}, nil
}
func (f *fakeDedupeClient) RemoveOpportunityMember(ctx context.Context, oppID, contactID string) (map[string]any, error) {
f.removedMember = append(f.removedMember, [2]string{oppID, contactID})
return map[string]any{}, nil
}
func (f *fakeDedupeClient) UpdateOpportunityTitle(ctx context.Context, id, newTitle string) (map[string]any, error) {
if f.renamed == nil {
f.renamed = make(map[string]string)
}
f.renamed[id] = newTitle
return map[string]any{"title": newTitle}, nil
}
func TestDedupeCompaniesOrchestration(t *testing.T) {
f := &fakeDedupeClient{
contacts: []map[string]any{
{"id": float64(857), "displayName": "contoso", "isCompany": true},
{"id": float64(908), "displayName": "contoso", "isCompany": true},
},
}
res, err := DedupeCompanies(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Merged != 1 || res.Groups != 1 {
t.Fatalf("res %+v", res)
}
want := [][2]string{{"857", "908"}}
if !reflect.DeepEqual(f.merged, want) {
t.Fatalf("merged %v", f.merged)
}
}
func TestDedupeOpportunityMembersOrchestration(t *testing.T) {
f := &fakeDedupeClient{
opportunities: []map[string]any{{"id": float64(231), "title": " @ contoso"}},
oppByID: map[string]map[string]any{
"231": {
"id": float64(231),
"members": []any{
map[string]any{"id": float64(857), "displayName": "contoso"},
map[string]any{"id": float64(908), "displayName": "contoso"},
},
},
},
}
res, err := DedupeOpportunityMembers(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Removed != 1 {
t.Fatalf("res %+v", res)
}
if len(f.removedMember) != 1 || f.removedMember[0][1] != "908" {
t.Fatalf("removed %v", f.removedMember)
}
}
func TestFixOpportunityTitlesOrchestration(t *testing.T) {
f := &fakeDedupeClient{
opportunities: []map[string]any{{"id": float64(231), "title": " @ contoso"}},
oppByID: map[string]map[string]any{"231": {"id": float64(231), "title": " @ contoso"}},
}
res, err := FixOpportunityTitles(context.Background(), f)
if err != nil {
t.Fatal(err)
}
if res.Renamed != 1 {
t.Fatalf("res %+v", res)
}
if f.renamed["231"] != "contoso" {
t.Fatalf("renamed %v", f.renamed)
}
}
+129
View File
@@ -0,0 +1,129 @@
//go:build integration
package onlyoffice
import (
"context"
"fmt"
"strconv"
"strings"
"testing"
"time"
)
const testCRMPrefix = "go-onlyoffice-test-"
func TestIntegrationMergeContacts(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
name := testCRMPrefix + "merge-" + suffix
a, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany a: %v", err)
}
b, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany b: %v", err)
}
aID := strconv.FormatInt(flexInt(a["id"]), 10)
bID := strconv.FormatInt(flexInt(b["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteContact(ctx, aID) })
if _, err := c.MergeContacts(ctx, aID, bID); err != nil {
t.Fatalf("MergeContacts: %v", err)
}
got, err := c.GetContact(ctx, aID)
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if got == nil {
t.Fatal("primary contact missing after merge")
}
}
func TestIntegrationUpdateOpportunityTitle(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
stages, err := c.ListDealStages(ctx)
if err != nil || len(stages) == 0 {
t.Fatalf("ListDealStages: %v", err)
}
stageID := int(flexInt(stages[0]["id"]))
title := testCRMPrefix + "title-" + strconv.FormatInt(time.Now().UnixNano(), 10)
opp, err := c.CreateOpportunity(ctx, title, stageID, "", "EUR", "", 0)
if err != nil {
t.Fatalf("CreateOpportunity: %v", err)
}
id := strconv.FormatInt(flexInt(opp["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
newTitle := title + "-renamed"
if _, err := c.UpdateOpportunityTitle(ctx, id, newTitle); err != nil {
t.Fatalf("UpdateOpportunityTitle: %v", err)
}
got, err := c.GetOpportunity(ctx, id)
if err != nil {
t.Fatalf("GetOpportunity: %v", err)
}
if strings.TrimSpace(fmt.Sprint(got["title"])) != newTitle {
t.Fatalf("title %q want %q", got["title"], newTitle)
}
}
func TestIntegrationDedupeCompaniesSmoke(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
name := testCRMPrefix + "dedupe-" + suffix
co1, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany: %v", err)
}
co2, err := c.CreateCompany(ctx, name)
if err != nil {
t.Fatalf("CreateCompany: %v", err)
}
t.Cleanup(func() {
id1 := strconv.FormatInt(flexInt(co1["id"]), 10)
_, _ = c.DeleteContact(ctx, id1)
})
res, err := DedupeCompanies(ctx, c)
if err != nil {
t.Fatalf("DedupeCompanies: %v", err)
}
if res.Merged < 1 {
t.Fatalf("expected merge, got %+v", res)
}
_ = co2
}
func TestIntegrationFixOpportunityTitleDeal231Pattern(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
stages, err := c.ListDealStages(ctx)
if err != nil || len(stages) == 0 {
t.Fatalf("ListDealStages: %v", err)
}
stageID := int(flexInt(stages[0]["id"]))
opp, err := c.CreateOpportunity(ctx, " @ contoso-test", stageID, "", "EUR", "", 0)
if err != nil {
t.Fatalf("CreateOpportunity: %v", err)
}
id := strconv.FormatInt(flexInt(opp["id"]), 10)
t.Cleanup(func() { _, _ = c.DeleteOpportunity(ctx, id) })
fixed := FixDealTitle(" @ contoso-test")
if _, err := c.UpdateOpportunityTitle(ctx, id, fixed); err != nil {
t.Fatalf("UpdateOpportunityTitle: %v", err)
}
got, err := c.GetOpportunity(ctx, id)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(got["title"]) != "contoso-test" {
t.Fatalf("title %q", got["title"])
}
}
+108
View File
@@ -0,0 +1,108 @@
package onlyoffice
import (
"regexp"
"strings"
)
var multiSpace = regexp.MustCompile(`\s+`)
// sloganSeparators split a company name from a trailing tagline/slogan.
var sloganSeparators = []string{" — ", " – ", " - ", "—", "–"}
// StripSloganSuffix returns the part before an em/en dash tagline, e.g.
// "Affirm — Fraud Engineering" → "Affirm".
func StripSloganSuffix(s string) string {
s = strings.TrimSpace(s)
for _, sep := range sloganSeparators {
if i := strings.Index(s, sep); i > 0 {
return strings.TrimSpace(s[:i])
}
}
return s
}
// CompanyGroupingKey normalizes a company name for dedupe (ignores slogans).
func CompanyGroupingKey(s string) string {
return NormalizeCompanyName(StripSloganSuffix(s))
}
// NormalizeCompanyName lowercases and collapses whitespace for grouping.
func NormalizeCompanyName(s string) string {
return collapseKey(s)
}
// NormalizePersonKey builds a grouping key from first and last name.
func NormalizePersonKey(first, last string) string {
return collapseKey(strings.TrimSpace(first) + " " + strings.TrimSpace(last))
}
// NormalizeOpportunityTitle lowercases and trims a deal title for exact dedupe.
func NormalizeOpportunityTitle(s string) string {
return collapseKey(s)
}
// StripCompanySuffix removes a trailing " @ Company" segment when present.
func StripCompanySuffix(title string) string {
title = strings.TrimSpace(title)
if i := strings.LastIndex(title, " @ "); i >= 0 {
return strings.TrimSpace(title[:i])
}
return title
}
// FixDealTitle strips a leading @, normalizes separator spacing, and collapses
// empty-position titles like " @ contoso" to "contoso".
func FixDealTitle(s string) string {
s = strings.TrimSpace(s)
for strings.HasPrefix(s, "@") {
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
}
if s == "" {
return ""
}
if i := strings.Index(s, "@"); i >= 0 {
left := strings.TrimSpace(s[:i])
right := strings.TrimSpace(s[i+1:])
if left == "" && right != "" {
return right
}
if left != "" && right != "" {
return left + " @ " + right
}
}
return s
}
// ContactInfoKey groups contact info rows by type and normalized value.
func ContactInfoKey(infoType, value string) string {
return strings.ToLower(strings.TrimSpace(infoType)) + "|" + strings.ToLower(strings.TrimSpace(value))
}
// MemberDisplayKey normalizes a member displayName for duplicate detection.
func MemberDisplayKey(displayName string) string {
return CompanyGroupingKey(displayName)
}
func collapseKey(s string) string {
s = strings.TrimSpace(s)
s = multiSpace.ReplaceAllString(s, " ")
return strings.ToLower(s)
}
// OpportunityTitlesMatch reports whether two deal titles refer to the same role+company.
func OpportunityTitlesMatch(a, b string) bool {
return DealTitleKey(a, false) == DealTitleKey(b, false)
}
func DealTitleForApplication(position, company string) string {
position = strings.TrimSpace(position)
company = strings.TrimSpace(company)
if company == "" {
return position
}
if position == "" {
return company
}
return position + " @ " + company
}
+104
View File
@@ -0,0 +1,104 @@
package onlyoffice
import "testing"
func TestNormalizeCompanyName(t *testing.T) {
tests := []struct {
in, want string
}{
{" contoso ", "contoso"},
{"Contoso", "contoso"},
{"Acme Corp", "acme corp"},
}
for _, tc := range tests {
if got := NormalizeCompanyName(tc.in); got != tc.want {
t.Errorf("NormalizeCompanyName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestNormalizePersonKey(t *testing.T) {
if got := NormalizePersonKey(" Jane ", " Doe "); got != "jane doe" {
t.Fatalf("got %q", got)
}
}
func TestFixDealTitle(t *testing.T) {
tests := []struct {
in, want string
}{
{" @ contoso", "contoso"},
{"@ contoso", "contoso"},
{"@Acme", "Acme"},
{"Dev@Acme", "Dev @ Acme"},
{"Dev @ Acme", "Dev @ Acme"},
{"Senior Dev @ Acme", "Senior Dev @ Acme"},
{"", ""},
}
for _, tc := range tests {
if got := FixDealTitle(tc.in); got != tc.want {
t.Errorf("FixDealTitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStripSloganSuffix(t *testing.T) {
tests := []struct {
in, want string
}{
{"Affirm — Fraud Engineering", "Affirm"},
{"Affirm - Fraud Engineering", "Affirm"},
{"Affirm – Fraud Engineering", "Affirm"},
{"Affirm", "Affirm"},
{"— leading", "— leading"},
}
for _, tc := range tests {
if got := StripSloganSuffix(tc.in); got != tc.want {
t.Errorf("StripSloganSuffix(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestCompanyGroupingKey(t *testing.T) {
a := CompanyGroupingKey("Affirm")
b := CompanyGroupingKey("Affirm — Fraud Engineering")
if a != b {
t.Fatalf("%q != %q", a, b)
}
}
func TestOpportunityTitlesMatchSlogan(t *testing.T) {
if !OpportunityTitlesMatch("Dev @ Affirm — Fraud Engineering", "Dev @ Affirm") {
t.Fatal("expected match")
}
}
func TestStripCompanySuffix(t *testing.T) {
if got := StripCompanySuffix("Dev @ Acme"); got != "Dev" {
t.Fatalf("got %q", got)
}
if got := StripCompanySuffix("Dev"); got != "Dev" {
t.Fatalf("got %q", got)
}
}
func TestContactInfoKey(t *testing.T) {
if got := ContactInfoKey("Email", " A@B.COM "); got != "email|a@b.com" {
t.Fatalf("got %q", got)
}
}
func TestMemberDisplayKey(t *testing.T) {
if got := MemberDisplayKey(" contoso "); got != "contoso" {
t.Fatalf("got %q", got)
}
}
func TestDealTitleForApplication(t *testing.T) {
if got := DealTitleForApplication("", "contoso"); got != "contoso" {
t.Fatalf("got %q", got)
}
if got := DealTitleForApplication("Dev", "Acme"); got != "Dev @ Acme" {
t.Fatalf("got %q", got)
}
}
+62
View File
@@ -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"])
}

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