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
This commit is contained in:
2026-04-24 14:55:21 +01:00
parent d5d249424b
commit cff8145f8c
28 changed files with 1757 additions and 1832 deletions
+2 -1
View File
@@ -5,6 +5,7 @@
go.work
go.work.sum
# Build artifacts (compiled top-level binary only; do NOT match cmd/oo-cli source tree)
# Build artifacts (top-level compiled binaries; cmd/oo/ source tree is tracked)
/oo
/oo-cli
/bin/
+12 -4
View File
@@ -1,17 +1,25 @@
# AGENTS — go-onlyoffice
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo-cli` command.
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo` command.
## Topology
- **Library (root package `onlyoffice`)** — `onlyoffice.go`, `httpx.go`, `calendar.go`, `crm.go`, `tasks_extra.go`, `files.go`. Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI (`cmd/oo-cli` + `internal/cli`)** — Cobra wrapper that loads `.env` via `godotenv` at startup. CLI-only deps (`spf13/cobra`, `joho/godotenv`) must stay out of the library surface.
- **Applications sync (`internal/applications`)** — README/CRM bridge, consumes the library.
- **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` — typed / untyped domain methods.
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. Split by domain: `main.go`, `common.go`, `calendar.go`, `tasks.go`, `crm.go`, `apps.go`. CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **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.
- 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`.
+62
View File
@@ -6,6 +6,68 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [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
+12 -11
View File
@@ -535,21 +535,23 @@ company, _ := client.FindCompany(ctx, "ACME")
client.AddSubtask(ctx, "4242", "Prepare CV")
```
### oo-cli (bundled command)
### oo (bundled command)
A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo-cli`:
A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo`:
```bash
go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
oo-cli cal-events
oo-cli task-list --all --verbose
oo-cli subtask-add 4242 "Prepare CV"
oo-cli applications-sync --path ./applications/2026 --apply
go install github.com/eslider/go-onlyoffice/cmd/oo@latest
oo cal-events
oo task-list --all --verbose
oo subtask-add 4242 "Prepare CV"
oo applications-sync --path ./applications/2026 --apply
```
The CLI reads `.env` from CWD (godotenv is a CLI-only concern — the library
itself never loads dotfiles). See [`cmd/oo-cli/README.md`](cmd/oo-cli/) for
the command reference.
itself never loads dotfiles). Run `oo --help` for the command reference.
> **0.4.0 migration note:** the binary was previously named `oo-cli` and
> lived at `cmd/oo-cli`. The command set and flags are unchanged.
## Environment Variables
@@ -569,8 +571,7 @@ the command reference.
| [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 |
| [applications](examples/applications/) | Job application folder tree → OnlyOffice CRM (library-level usage of the internal applications package) |
| [`cmd/oo-cli`](cmd/oo-cli/) | Full-featured CLI using all modules |
| [`cmd/oo`](cmd/oo/) | Full-featured CLI using all modules |
## Related Libraries
+121
View File
@@ -0,0 +1,121 @@
package onlyoffice
// Authentication primitives: token lifecycle, eager / context-aware auth, and
// token invalidation. Split out of http.go so auth concerns live together.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Auth authenticates using the given credentials and returns a fresh token.
// Most callers should not call this directly — use Authenticate /
// AuthenticateContext, or let Query fetch a token lazily. Kept exported for
// backwards compatibility.
func (c *Client) Auth(creds *Credentials) (*Token, error) {
t := &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: http.MethodPost,
Body: creds,
NoAuth: true,
}, &struct {
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token
// is fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs
// a POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron,
// watchers) because it guarantees that a stalled auth call will not block
// the caller past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.tokenValid() {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// tokenValid reports whether the cached token is present and not expired.
func (c *Client) tokenValid() bool {
return c.token != nil && !time.Time(c.token.Expires).Before(time.Now())
}
// ensureToken refreshes the authentication token when missing or expired.
// Mirrors the logic inline in Query() but is safe to call from helpers that
// bypass the typed Request abstraction.
func (c *Client) ensureToken() error {
if c.tokenValid() {
return nil
}
tok, err := c.Auth(c.credentials)
if err != nil {
return err
}
c.token = tok
return nil
}
// authHeader returns the value for the Authorization header, ensuring a token.
func (c *Client) authHeader() (string, error) {
if err := c.ensureToken(); err != nil {
return "", err
}
return c.token.Value, nil
}
+6 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"strconv"
)
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
@@ -29,6 +30,9 @@ func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[strin
// AddEvent creates a simple one-shot calendar event. When calendarID is empty
// 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
@@ -43,7 +47,7 @@ func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, de
fields.Set("endDate", end)
fields.Set("repeatType", "")
fields.Set("alertType", "0")
fields.Set("isAllDayLong", fmt.Sprintf("%t", allDay))
fields.Set("isAllDayLong", strconv.FormatBool(allDay))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
if err != nil {
return nil, err
@@ -64,15 +68,5 @@ func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, de
// DeleteEvent removes a single calendar event by its event ID.
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/calendar/events/%s.json", url.PathEscape(eventID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
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 ""
}
-47
View File
@@ -1,47 +0,0 @@
# oo-cli
Cobra CLI wrapper around [`github.com/eslider/go-onlyoffice`](../..). Replaces the Python scripts that used to live under `eSlider/cv/bin/office`.
## Install
```bash
go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
```
Ensure `$(go env GOPATH)/bin` is on `PATH`.
## Config
Copy [`../../.env.example`](../../.env.example) to `.env` in the working directory:
| Variable | Alias | Default |
|---|---|---|
| `ONLYOFFICE_URL` | `ONLYOFFICE_HOST` | — |
| `ONLYOFFICE_USER` | `ONLYOFFICE_NAME` | — |
| `ONLYOFFICE_PASS` | `ONLYOFFICE_PASSWORD` | — |
| `ONLYOFFICE_CALENDAR_ID` | — | `1` |
| `ONLYOFFICE_PROJECT_ID` | `ONLYOFFICE_CALENDAR_PROJECT_ID` | `33` |
## Commands
| Python (legacy) | Go CLI |
|---|---|
| `list-calendars.py` | `oo-cli cal-list` |
| `list-events.py` | `oo-cli cal-events [--start] [--end]` |
| `add-event.py` | `oo-cli cal-add TITLE START END [--calendar] [--description] [--all-day]` |
| `delete-event.py` | `oo-cli cal-delete ID [ID...]` |
| `list-tasks.py` | `oo-cli task-list [--project] [--status] [--all] [--verbose]` |
| `add-task.py` | `oo-cli task-add TITLE [--project] [--description] [--deadline] [--priority]` |
| `add-subtask.py` | `oo-cli subtask-add PARENT TITLE [TITLE...]` |
| `update-task.py` | `oo-cli task-update ID open\|closed` or `--delete` |
| `list-contacts.py` | `oo-cli crm-contacts [--companies\|--persons] [--search]` |
| `add-contact.py` | `oo-cli crm-add-contact --company ...` or `--person-first` / `--person-last` |
| `list-deals.py` | `oo-cli crm-deals` / `oo-cli crm-deals --stages` |
| `add-deal.py` | `oo-cli crm-add-deal TITLE [--stage] [--bid] [--contact]` |
| `list-cases.py` | `oo-cli crm-cases` |
| `sync-applications.py` | `oo-cli applications-sync --path .../applications/2026 [--apply]` |
## Notes
- The CLI loads `.env` via `godotenv`; the library itself does not.
- `applications-sync` defaults to dry-run; pass `--apply` to write to CRM.
-20
View File
@@ -1,20 +0,0 @@
// Command oo-cli is a thin CLI wrapper over the github.com/eslider/go-onlyoffice
// library. It exposes calendar, CRM, tasks, subtasks, and application-sync
// commands. Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
package main
import (
"fmt"
"os"
"github.com/eslider/go-onlyoffice/internal/cli"
)
func main() {
if err := cli.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -1,5 +1,10 @@
// Package applications syncs job application README trees into OnlyOffice CRM
// (ported from cv/bin/office/sync-applications.py).
//
// This package lives under cmd/oo because it is a CV-specific workflow — not
// a general OnlyOffice feature — and is only consumed by the `oo` CLI.
// Keeping it in the binary tree prevents accidental adoption by external
// library consumers and makes the coupling explicit.
package applications
import (
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"fmt"
"github.com/eslider/go-onlyoffice/cmd/oo/applications"
"github.com/spf13/cobra"
)
func cmdAppsSync() *cobra.Command {
var apply, verbose bool
var base string
cmd := &cobra.Command{
Use: "applications-sync",
Short: "Sync applications/*/README.md into CRM",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if base == "" {
return fmt.Errorf("use --path to applications year dir (e.g. .../applications/2026)")
}
paths, err := applications.Discover(base)
if err != nil {
return err
}
var apps []applications.Data
for _, p := range paths {
a, err := applications.ParseReadme(p)
if err != nil {
return err
}
if a.Position == "" && a.Company == "" {
continue
}
apps = append(apps, a)
}
fmt.Printf("Found %d application(s)\n", len(apps))
applications.Sync(cmd.Context(), c, apps, !apply, verbose)
return nil
},
}
cmd.Flags().BoolVar(&apply, "apply", false, "write to CRM (default dry-run)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
cmd.Flags().StringVar(&base, "path", "", "applications base directory")
return cmd
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"time"
"github.com/spf13/cobra"
)
func cmdCalList() *cobra.Command {
return &cobra.Command{
Use: "cal-list",
Short: "List calendars (default date span)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
out, err := c.ListCalendars(cmd.Context(), "", "")
if err != nil {
return err
}
printJSON(out)
return nil
},
}
}
func cmdCalEvents() *cobra.Command {
var start, end string
cmd := &cobra.Command{
Use: "cal-events",
Short: "List calendar data for period (default: next 7 days)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if start == "" || end == "" {
start = time.Now().Format("2006-01-02")
end = time.Now().AddDate(0, 0, 7).Format("2006-01-02")
}
out, err := c.ListEvents(cmd.Context(), start, end)
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD")
cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD")
return cmd
}
func cmdCalAdd() *cobra.Command {
var cal, desc string
var allDay bool
cmd := &cobra.Command{
Use: "cal-add TITLE START END",
Short: "Add calendar event",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay)
if err != nil {
return err
}
printJSON(ev)
return nil
},
}
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env)")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().BoolVar(&allDay, "all-day", false, "")
return cmd
}
func cmdCalDel() *cobra.Command {
return &cobra.Command{
Use: "cal-delete EVENT_ID [EVENT_ID...]",
Short: "Delete calendar events",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteEvent(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
}
return nil
},
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "oo",
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
}
// execute runs the root command. Exported only to main.go in the same package.
func execute() error { return rootCmd.Execute() }
func init() {
rootCmd.AddCommand(cmdCalList())
rootCmd.AddCommand(cmdCalEvents())
rootCmd.AddCommand(cmdCalAdd())
rootCmd.AddCommand(cmdCalDel())
rootCmd.AddCommand(cmdTaskList())
rootCmd.AddCommand(cmdTaskAdd())
rootCmd.AddCommand(cmdSubtaskAdd())
rootCmd.AddCommand(cmdTaskUpdate())
rootCmd.AddCommand(cmdCRMContacts())
rootCmd.AddCommand(cmdCRMAddContact())
rootCmd.AddCommand(cmdCRMDeals())
rootCmd.AddCommand(cmdCRMAddDeal())
rootCmd.AddCommand(cmdCRMCases())
rootCmd.AddCommand(cmdAppsSync())
}
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO() (*onlyoffice.Client, error) {
_ = godotenv.Load()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.Authenticate(); err != nil {
return nil, err
}
return c, nil
}
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
// flexIDFloat coerces OnlyOffice numeric id fields surfaced as float64 / int
// / string into a float64. CLI-only; the library's own flexInt is unexported.
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
}
}
+181
View File
@@ -0,0 +1,181 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func cmdCRMContacts() *cobra.Command {
var companies, persons bool
var search string
cmd := &cobra.Command{
Use: "crm-contacts",
Short: "List CRM contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, _, err := c.ListContacts(cmd.Context(), 50, 0, search)
if err != nil {
return err
}
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if companies && !isCo {
continue
}
if persons && isCo {
continue
}
printJSON(row)
}
return nil
},
}
cmd.Flags().BoolVar(&companies, "companies", false, "")
cmd.Flags().BoolVar(&persons, "persons", false, "")
cmd.Flags().StringVar(&search, "search", "", "")
return cmd
}
func cmdCRMAddContact() *cobra.Command {
var company, email, website, linkedin string
var personFirst, personLast string
var companyID int
cmd := &cobra.Command{
Use: "crm-add-contact",
Short: "Add company or person",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if company != "" {
out, err := c.CreateCompany(cmd.Context(), company)
if err != nil {
return err
}
cid := fmt.Sprint(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)
}
printJSON(out)
return nil
}
if personFirst != "" && personLast != "" {
out, err := c.CreatePerson(cmd.Context(), personFirst, personLast, companyID, "", "")
if err != nil {
return err
}
pid := fmt.Sprint(int(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)
}
printJSON(out)
return nil
}
return fmt.Errorf("set --company or --person-first and --person-last")
},
}
cmd.Flags().StringVar(&company, "company", "", "")
cmd.Flags().StringVar(&personFirst, "person-first", "", "")
cmd.Flags().StringVar(&personLast, "person-last", "", "")
cmd.Flags().IntVar(&companyID, "company-id", 0, "")
cmd.Flags().StringVar(&email, "email", "", "")
cmd.Flags().StringVar(&website, "website", "", "")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "")
return cmd
}
func cmdCRMDeals() *cobra.Command {
var stages bool
cmd := &cobra.Command{
Use: "crm-deals",
Short: "List opportunities / stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stages {
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printJSON(out)
return nil
}
list, total, err := c.ListOpportunities(cmd.Context(), 100, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
cmd.Flags().BoolVar(&stages, "stages", false, "")
return cmd
}
func cmdCRMAddDeal() *cobra.Command {
var stage int
var bid float64
var contacts []string
cmd := &cobra.Command{
Use: "crm-add-deal TITLE",
Short: "Create opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, "", "EUR", "", bid)
if err != nil {
return err
}
did := fmt.Sprint(int(flexIDFloat(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printJSON(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "")
cmd.Flags().Float64Var(&bid, "bid", 0, "")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id (repeatable)")
return cmd
}
func cmdCRMCases() *cobra.Command {
return &cobra.Command{
Use: "crm-cases",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), 50, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
}
+24
View File
@@ -0,0 +1,24 @@
// Command oo is a thin CLI over the github.com/eslider/go-onlyoffice library.
// It exposes calendar, CRM, tasks, subtasks, and application-sync commands.
//
// Build & install:
//
// go install github.com/eslider/go-onlyoffice/cmd/oo@latest
//
// Rationale: previous versions shipped as `oo-cli` with the cobra tree living
// in `internal/cli`. Since the CLI is a consumer of the library — not a part
// of its public surface — cobra lives right here in package main, split by
// domain (calendar.go, crm.go, tasks.go, apps.go).
package main
import (
"fmt"
"os"
)
func main() {
if err := execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func cmdTaskList() *cobra.Command {
var project, status string
var all, verbose bool
cmd := &cobra.Command{
Use: "task-list",
Short: "List project tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
var tasks []map[string]any
if all {
tasks, err = c.ListAllTasks(cmd.Context(), status)
} else {
tasks, err = c.ListTasks(cmd.Context(), project, status)
}
if err != nil {
return err
}
for _, t := range tasks {
fmt.Printf("[%v] %v status=%v\n", t["id"], t["title"], t["status"])
if verbose {
d, _ := c.GetTaskByID(cmd.Context(), fmt.Sprint(t["id"]))
subs, _ := d["subtasks"].([]any)
for _, s := range subs {
sm, _ := s.(map[string]any)
fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"])
}
}
}
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id")
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
return cmd
}
func cmdTaskAdd() *cobra.Command {
var project, desc, deadline string
var prio string
cmd := &cobra.Command{
Use: "task-add TITLE",
Short: "Add project task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
p := 0
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
}
printJSON(out)
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().StringVar(&deadline, "deadline", "", "")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd
}
func cmdSubtaskAdd() *cobra.Command {
return &cobra.Command{
Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add subtask(s)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
parent := args[0]
for _, title := range args[1:] {
out, err := c.AddSubtask(cmd.Context(), parent, title)
if err != nil {
return err
}
printJSON(out)
}
return nil
},
}
}
func cmdTaskUpdate() *cobra.Command {
var del bool
cmd := &cobra.Command{
Use: "task-update TASK_ID open|closed",
Short: "Update task status or delete",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
id := args[0]
if del {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
return nil
}
if len(args) < 2 {
return fmt.Errorf("need status or --delete")
}
out, err := c.UpdateTaskStatus(cmd.Context(), id, args[1])
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().BoolVar(&del, "delete", false, "delete task")
return cmd
}
+34 -193
View File
@@ -2,7 +2,7 @@ package onlyoffice
// Minimal CRM helpers: contacts, opportunities, cases, tasks, and history notes.
// These expose untyped maps for flexibility — they are primarily consumed by
// cmd/oo-cli and the applications-sync workflow.
// cmd/oo and the applications-sync workflow.
import (
"context"
@@ -16,8 +16,8 @@ import (
// 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", fmt.Sprintf("%d", count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex))
q.Set("count", strconv.Itoa(count))
q.Set("startIndex", strconv.Itoa(startIndex))
if search != "" {
q.Set("filterValue", search)
}
@@ -41,19 +41,7 @@ func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search
// GetContact returns a single contact by id.
func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
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)
@@ -78,13 +66,14 @@ func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string
if err != nil {
return nil, err
}
first = strings.ToLower(first)
last = strings.ToLower(last)
for _, p := range items {
if isCompany(p) {
continue
}
fn := strings.ToLower(fmt.Sprint(p["firstName"]))
ln := strings.ToLower(fmt.Sprint(p["lastName"]))
if fn == strings.ToLower(first) && ln == strings.ToLower(last) {
if strings.ToLower(fmt.Sprint(p["firstName"])) == first &&
strings.ToLower(fmt.Sprint(p["lastName"])) == last {
return p, nil
}
}
@@ -100,19 +89,7 @@ func isCompany(m map[string]any) bool {
func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) {
fields := url.Values{}
fields.Set("companyName", name)
raw, err := c.postForm(ctx, "/api/2.0/crm/contact/company.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
return c.postFormObject(ctx, "/api/2.0/crm/contact/company.json", fields)
}
// CreatePerson creates a person contact; companyID == 0 means unlinked.
@@ -129,19 +106,7 @@ func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID
if about != "" {
fields.Set("about", about)
}
raw, err := c.postForm(ctx, "/api/2.0/crm/contact/person.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
return c.postFormObject(ctx, "/api/2.0/crm/contact/person.json", fields)
}
// AddContactInfo attaches an email/website/phone/etc. to a contact.
@@ -153,40 +118,20 @@ func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataVa
fields.Set("infoType", infoType)
fields.Set("data", dataValue)
fields.Set("category", category)
fields.Set("isPrimary", fmt.Sprintf("%t", isPrimary))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s/data.json", url.PathEscape(contactID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
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) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
}
// 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", fmt.Sprintf("%d", count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex))
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
@@ -203,19 +148,7 @@ func (c *Client) ListOpportunities(ctx context.Context, count, startIndex int) (
// GetOpportunity returns a single opportunity (deal) by id.
func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
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
@@ -237,39 +170,19 @@ func (c *Client) CreateOpportunity(ctx context.Context, title string, stageID in
fields.Set("responsibleId", responsibleID)
fields.Set("bidCurrencyAbbr", bidCurrency)
if bidValue != 0 {
fields.Set("bidValue", fmt.Sprintf("%g", bidValue))
fields.Set("bidValue", strconv.FormatFloat(bidValue, 'g', -1, 64))
}
if description != "" {
fields.Set("description", description)
}
raw, err := c.postForm(ctx, "/api/2.0/crm/opportunity.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
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) {
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s/contact/%s.json", url.PathEscape(oppID), url.PathEscape(contactID)), url.Values{})
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
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.
@@ -279,24 +192,14 @@ func (c *Client) ListDealStages(ctx context.Context) ([]map[string]any, error) {
// DeleteOpportunity removes a deal by id.
func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/opportunity/%s.json", url.PathEscape(id)))
}
// 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", fmt.Sprintf("%d", count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex))
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
@@ -315,56 +218,26 @@ func (c *Client) ListCases(ctx context.Context, count, startIndex int) ([]map[st
func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
raw, err := c.postForm(ctx, "/api/2.0/crm/case.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
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) {
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/crm/case/%s/contact/%s.json", url.PathEscape(caseID), url.PathEscape(contactID)), url.Values{})
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
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) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/case/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
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", fmt.Sprintf("%d", count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex))
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
@@ -397,34 +270,12 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
if description != "" {
fields.Set("description", description)
}
raw, err := c.postForm(ctx, "/api/2.0/crm/task.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
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) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
}
// ListTaskCategories returns CRM task categories.
@@ -447,17 +298,7 @@ func (c *Client) AddHistoryNote(ctx context.Context, entityType string, entityID
fields.Set("entityId", strconv.Itoa(entityID))
fields.Set("content", content)
fields.Set("categoryId", strconv.Itoa(categoryID))
raw, err := c.postForm(ctx, "/api/2.0/crm/history.json", fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
return c.postFormObject(ctx, "/api/2.0/crm/history.json", fields)
}
func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) {
-54
View File
@@ -1,54 +0,0 @@
// Package main shows how to drive the internal applications package — the same
// logic that powers `oo-cli applications-sync` — directly from Go code.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// go run ./examples/applications /path/to/cv/applications
//
// Without --apply equivalent, it prints a summary but performs no writes.
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/applications"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: applications <applications-root>")
os.Exit(2)
}
root := os.Args[1]
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
paths, err := applications.Discover(root)
if err != nil {
log.Fatalf("discover: %v", err)
}
fmt.Printf("discovered %d application READMEs under %s\n", len(paths), root)
var apps []applications.Data
for _, p := range paths {
d, err := applications.ParseReadme(p)
if err != nil {
log.Printf("parse %s: %v", p, err)
continue
}
apps = append(apps, d)
}
stats := applications.Sync(context.Background(), client, apps, true /*dryRun*/, true /*verbose*/)
fmt.Printf("\ndry-run stats: %+v\n", stats)
}
+1 -10
View File
@@ -2,7 +2,6 @@ package onlyoffice
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
@@ -15,13 +14,5 @@ func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, fileP
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
return unmarshalResponseObject(raw)
}
+65 -108
View File
@@ -1,8 +1,10 @@
package onlyoffice
// Lightweight JSON/form/multipart helpers complementing the typed Query()
// abstraction in onlyoffice.go. These are used by the Calendar, CRM, and
// subtask helpers that talk to non-JSON endpoints (form-encoded or multipart).
// Transport-layer helpers used by the untyped domain methods (CRM, calendar,
// tasks, files). Authentication lives in auth.go; the typed Request/Query
// abstraction lives in request.go. These helpers deliberately share the
// `*Client` state with the typed path so token refresh, base URL, and
// self-id caching are handled once.
import (
"bytes"
@@ -16,92 +18,8 @@ import (
"os"
"path/filepath"
"strings"
"time"
)
// ensureToken refreshes the authentication token when missing or expired.
// Mirrors the logic inline in Query() but is safe to call from helpers that
// bypass the typed Request abstraction.
func (c *Client) ensureToken() error {
if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) {
return nil
}
tok, err := c.Auth(c.credentials)
if err != nil {
return err
}
c.token = tok
return nil
}
// authHeader returns the value for the Authorization header, ensuring a token.
func (c *Client) authHeader() (string, error) {
if err := c.ensureToken(); err != nil {
return "", err
}
return c.token.Value, nil
}
// Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token is
// fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs a
// POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron, watchers)
// because it guarantees that a stalled auth call will not block the caller
// past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// baseURL returns the configured base URL without trailing slash.
func (c *Client) baseURL() string {
return strings.TrimRight(c.credentials.Url, "/")
@@ -151,6 +69,66 @@ func (c *Client) ResponseArray(ctx context.Context, path string) ([]map[string]a
return list, nil
}
// ResponseObject executes a GET and decodes the "response" field into a map.
// Returns (nil, nil) when the field is JSON null or absent. Companion to
// ResponseArray — factored out to eliminate the ~15 identical decode blocks
// in crm.go / tasks.go / calendar.go.
func (c *Client) ResponseObject(ctx context.Context, path string) (map[string]any, error) {
raw, err := c.getJSON(ctx, path)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// postFormObject issues an authenticated POST (form-encoded) and decodes the
// "response" field into a map.
func (c *Client) postFormObject(ctx context.Context, path string, fields url.Values) (map[string]any, error) {
raw, err := c.postForm(ctx, path, fields)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// putFormObject issues an authenticated PUT (form-encoded) and decodes the
// "response" field into a map.
func (c *Client) putFormObject(ctx context.Context, path string, fields url.Values) (map[string]any, error) {
raw, err := c.putForm(ctx, path, fields)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// deleteObject issues an authenticated DELETE and decodes the "response"
// field into a map.
func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, path)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// unmarshalResponseObject extracts the "response" field from a raw OnlyOffice
// envelope and decodes it into map[string]any. Returns (nil, nil) for a null
// response and (nil, err) when the field is missing or malformed.
func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if len(resp) == 0 || string(resp) == "null" {
return nil, nil
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// getJSON issues an authenticated GET and returns the raw response body.
func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) {
auth, err := c.authHeader()
@@ -242,27 +220,6 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e
return raw, nil
}
// SelfUserID returns the ID of the authenticated user (people/@self), cached.
func (c *Client) SelfUserID(ctx context.Context) (string, error) {
if c.selfID != "" {
return c.selfID, nil
}
raw, err := c.getJSON(ctx, "/api/2.0/people/@self.json")
if err != nil {
return "", err
}
var env struct {
Response struct {
ID string `json:"id"`
} `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", err
}
c.selfID = env.Response.ID
return c.selfID, nil
}
// uploadMultipart posts a single file to path under the given form field name.
func (c *Client) uploadMultipart(ctx context.Context, path, fieldName, filePath string) (json.RawMessage, error) {
auth, err := c.authHeader()
-517
View File
@@ -1,517 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/applications"
"github.com/joho/godotenv"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "oo-cli",
Short: "OnlyOffice Workspace CLI (Go port of cv/bin/office)",
}
// Execute runs the root command; the binary in cmd/oo-cli is a thin wrapper.
func Execute() error {
return rootCmd.Execute()
}
// newOO loads env (incl. .env in CWD) and returns an authenticated client.
// godotenv is a CLI-only concern; the library itself never loads dotfiles.
func newOO() (*onlyoffice.Client, error) {
_ = godotenv.Load()
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" || creds.User == "" || creds.Password == "" {
return nil, fmt.Errorf("need ONLYOFFICE_URL (or ONLYOFFICE_HOST), user (ONLYOFFICE_USER or ONLYOFFICE_NAME), password (ONLYOFFICE_PASS or ONLYOFFICE_PASSWORD)")
}
c := onlyoffice.NewClient(creds)
c.SetDefaults(onlyoffice.GetEnvironmentDefaults())
if err := c.Authenticate(); err != nil {
return nil, err
}
return c, nil
}
func printJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func init() {
rootCmd.AddCommand(cmdCalList())
rootCmd.AddCommand(cmdCalEvents())
rootCmd.AddCommand(cmdCalAdd())
rootCmd.AddCommand(cmdCalDel())
rootCmd.AddCommand(cmdTaskList())
rootCmd.AddCommand(cmdTaskAdd())
rootCmd.AddCommand(cmdSubtaskAdd())
rootCmd.AddCommand(cmdTaskUpdate())
rootCmd.AddCommand(cmdCRMContacts())
rootCmd.AddCommand(cmdCRMAddContact())
rootCmd.AddCommand(cmdCRMDeals())
rootCmd.AddCommand(cmdCRMAddDeal())
rootCmd.AddCommand(cmdCRMCases())
rootCmd.AddCommand(cmdAppsSync())
}
func cmdCalList() *cobra.Command {
return &cobra.Command{
Use: "cal-list",
Short: "List calendars (default date span)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
out, err := c.ListCalendars(cmd.Context(), "", "")
if err != nil {
return err
}
printJSON(out)
return nil
},
}
}
func cmdCalEvents() *cobra.Command {
var start, end string
cmd := &cobra.Command{
Use: "cal-events",
Short: "List calendar data for period (default: next 7 days)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if start == "" || end == "" {
// Match list-events.py default window
start = time.Now().Format("2006-01-02")
end = time.Now().AddDate(0, 0, 7).Format("2006-01-02")
}
out, err := c.ListEvents(cmd.Context(), start, end)
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().StringVar(&start, "start", "", "start date YYYY-MM-DD")
cmd.Flags().StringVar(&end, "end", "", "end date YYYY-MM-DD")
return cmd
}
func cmdCalAdd() *cobra.Command {
var cal, desc string
var allDay bool
cmd := &cobra.Command{
Use: "cal-add TITLE START END",
Short: "Add calendar event",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
ev, err := c.AddEvent(cmd.Context(), cal, args[0], args[1], args[2], desc, allDay)
if err != nil {
return err
}
printJSON(ev)
return nil
},
}
cmd.Flags().StringVar(&cal, "calendar", "", "calendar id (default from env)")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().BoolVar(&allDay, "all-day", false, "")
return cmd
}
func cmdCalDel() *cobra.Command {
return &cobra.Command{
Use: "cal-delete EVENT_ID [EVENT_ID...]",
Short: "Delete calendar events",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
for _, id := range args {
out, err := c.DeleteEvent(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
}
return nil
},
}
}
func cmdTaskList() *cobra.Command {
var project, status string
var all, verbose bool
cmd := &cobra.Command{
Use: "task-list",
Short: "List project tasks",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
var tasks []map[string]interface{}
if all {
tasks, err = c.ListAllTasks(cmd.Context(), status)
} else {
tasks, err = c.ListTasks(cmd.Context(), project, status)
}
if err != nil {
return err
}
for _, t := range tasks {
fmt.Printf("[%v] %v status=%v\n", t["id"], t["title"], t["status"])
if verbose {
d, _ := c.GetTaskByID(cmd.Context(), fmt.Sprint(t["id"]))
subs, _ := d["subtasks"].([]interface{})
for _, s := range subs {
sm, _ := s.(map[string]interface{})
fmt.Printf(" └─ [%v] %v\n", sm["id"], sm["title"])
}
}
}
return nil
},
}
cmd.Flags().StringVarP(&project, "project", "p", "", "project id")
cmd.Flags().StringVarP(&status, "status", "s", "", "open|closed")
cmd.Flags().BoolVarP(&all, "all", "a", false, "all projects (@self)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
return cmd
}
func cmdTaskAdd() *cobra.Command {
var project, desc, deadline string
var prio string
cmd := &cobra.Command{
Use: "task-add TITLE",
Short: "Add project task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
p := 0
if prio == "high" {
p = 1
}
if prio == "low" {
p = -1
}
out, err := c.AddTask(cmd.Context(), project, args[0], desc, p, deadline)
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().StringVar(&project, "project", "", "")
cmd.Flags().StringVar(&desc, "description", "", "")
cmd.Flags().StringVar(&deadline, "deadline", "", "")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd
}
func cmdSubtaskAdd() *cobra.Command {
return &cobra.Command{
Use: "subtask-add PARENT_TASK_ID TITLE [TITLE...]",
Short: "Add subtask(s)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
parent := args[0]
for _, title := range args[1:] {
out, err := c.AddSubtask(cmd.Context(), parent, title)
if err != nil {
return err
}
printJSON(out)
}
return nil
},
}
}
func cmdTaskUpdate() *cobra.Command {
var del bool
cmd := &cobra.Command{
Use: "task-update TASK_ID open|closed",
Short: "Update task status or delete",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
id := args[0]
if del {
out, err := c.DeleteTask(cmd.Context(), id)
if err != nil {
return err
}
printJSON(out)
return nil
}
if len(args) < 2 {
return fmt.Errorf("need status or --delete")
}
out, err := c.UpdateTaskStatus(cmd.Context(), id, args[1])
if err != nil {
return err
}
printJSON(out)
return nil
},
}
cmd.Flags().BoolVar(&del, "delete", false, "delete task")
return cmd
}
func cmdCRMContacts() *cobra.Command {
var companies, persons bool
var search string
cmd := &cobra.Command{
Use: "crm-contacts",
Short: "List CRM contacts",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, _, err := c.ListContacts(cmd.Context(), 50, 0, search)
if err != nil {
return err
}
for _, row := range list {
isCo, _ := row["isCompany"].(bool)
if companies && !isCo {
continue
}
if persons && isCo {
continue
}
printJSON(row)
}
return nil
},
}
cmd.Flags().BoolVar(&companies, "companies", false, "")
cmd.Flags().BoolVar(&persons, "persons", false, "")
cmd.Flags().StringVar(&search, "search", "", "")
return cmd
}
func cmdCRMAddContact() *cobra.Command {
var company, email, website, linkedin string
var personFirst, personLast string
var companyID int
cmd := &cobra.Command{
Use: "crm-add-contact",
Short: "Add company or person",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if company != "" {
out, err := c.CreateCompany(cmd.Context(), company)
if err != nil {
return err
}
cid := int(flexIDMap(out["id"]))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Email", email, "Work", true)
}
if website != "" {
_, _ = c.AddContactInfo(cmd.Context(), fmt.Sprint(cid), "Website", website, "Work", false)
}
printJSON(out)
return nil
}
if personFirst != "" && personLast != "" {
out, err := c.CreatePerson(cmd.Context(), personFirst, personLast, companyID, "", "")
if err != nil {
return err
}
pid := fmt.Sprint(int(flexIDMap(out["id"])))
if email != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "Email", email, "Work", true)
}
if linkedin != "" {
_, _ = c.AddContactInfo(cmd.Context(), pid, "LinkedIn", linkedin, "Work", false)
}
printJSON(out)
return nil
}
return fmt.Errorf("set --company or --person-first and --person-last")
},
}
cmd.Flags().StringVar(&company, "company", "", "")
cmd.Flags().StringVar(&personFirst, "person-first", "", "")
cmd.Flags().StringVar(&personLast, "person-last", "", "")
cmd.Flags().IntVar(&companyID, "company-id", 0, "")
cmd.Flags().StringVar(&email, "email", "", "")
cmd.Flags().StringVar(&website, "website", "", "")
cmd.Flags().StringVar(&linkedin, "linkedin", "", "")
return cmd
}
func flexIDMap(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
func cmdCRMDeals() *cobra.Command {
var stages bool
cmd := &cobra.Command{
Use: "crm-deals",
Short: "List opportunities / stages",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stages {
out, err := c.ListDealStages(cmd.Context())
if err != nil {
return err
}
printJSON(out)
return nil
}
list, total, err := c.ListOpportunities(cmd.Context(), 100, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
cmd.Flags().BoolVar(&stages, "stages", false, "")
return cmd
}
func cmdCRMAddDeal() *cobra.Command {
var stage int
var bid float64
var contacts []string
cmd := &cobra.Command{
Use: "crm-add-deal TITLE",
Short: "Create opportunity",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if stage == 0 {
stage = 1
}
out, err := c.CreateOpportunity(cmd.Context(), args[0], stage, "", "EUR", "", bid)
if err != nil {
return err
}
did := fmt.Sprint(int(flexIDMap(out["id"])))
for _, cid := range contacts {
_, _ = c.AddOpportunityMember(cmd.Context(), did, cid)
}
printJSON(out)
return nil
},
}
cmd.Flags().IntVar(&stage, "stage", 1, "")
cmd.Flags().Float64Var(&bid, "bid", 0, "")
cmd.Flags().StringSliceVar(&contacts, "contact", nil, "contact id (repeatable)")
return cmd
}
func cmdCRMCases() *cobra.Command {
return &cobra.Command{
Use: "crm-cases",
Short: "List CRM cases",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
list, total, err := c.ListCases(cmd.Context(), 50, 0)
if err != nil {
return err
}
fmt.Println("total:", total)
printJSON(list)
return nil
},
}
}
func cmdAppsSync() *cobra.Command {
var apply, verbose bool
var base string
cmd := &cobra.Command{
Use: "applications-sync",
Short: "Sync applications/*/README.md into CRM",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO()
if err != nil {
return err
}
if base == "" {
return fmt.Errorf("use --path to applications year dir (e.g. .../applications/2026)")
}
paths, err := applications.Discover(base)
if err != nil {
return err
}
var apps []applications.Data
for _, p := range paths {
a, err := applications.ParseReadme(p)
if err != nil {
return err
}
if a.Position == "" && a.Company == "" {
continue
}
apps = append(apps, a)
}
fmt.Printf("Found %d application(s)\n", len(apps))
applications.Sync(cmd.Context(), c, apps, !apply, verbose)
return nil
},
}
cmd.Flags().BoolVar(&apply, "apply", false, "write to CRM (default dry-run)")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "")
cmd.Flags().StringVar(&base, "path", "", "applications base directory")
return cmd
}
-686
View File
@@ -1,686 +0,0 @@
package onlyoffice
// OnlyOffice client package
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/google/go-querystring/query"
)
// NewClient API
func NewClient(c Credentials) *Client {
return &Client{
client: http.DefaultClient,
credentials: &c,
}
}
// Defaults holds optional fallbacks used by package-level helpers when callers
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults.
type Defaults struct {
CalendarID string
ProjectID string
}
// SetDefaults configures optional identifiers used as fallbacks by methods such
// as AddEvent (when calendarID == "") or AddTask (when projectID == "").
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
// firstNonEmpty returns the first non-empty trimmed value, or "" if none found.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
//
// Primary variables (documented):
// - ONLYOFFICE_URL
// - ONLYOFFICE_USER
// - ONLYOFFICE_PASS
//
// Additional aliases accepted for interoperability with sibling tools:
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
func GetEnvironmentCredentials() Credentials {
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
url = strings.TrimRight(url, "/")
return Credentials{
Url: url,
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
}
}
// GetEnvironmentDefaults reads optional library defaults from environment:
//
// - ONLYOFFICE_CALENDAR_ID (default: "1")
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
func GetEnvironmentDefaults() Defaults {
return Defaults{
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
ProjectID: firstNonEmpty(
os.Getenv("ONLYOFFICE_PROJECT_ID"),
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
"33",
),
}
}
// Credentials of OnlyOffice User
type Credentials struct {
Url string `json:"-"`
User string `json:"userName"`
Password string `json:"password"`
}
// ToJson for Credentials
func (c Credentials) ToJson() []byte {
b, err := json.Marshal(c)
if err != nil {
return nil
}
return b
}
// Client of OnlyOffice API uses credentials to get a token and query the API by every request
type Client struct {
client *http.Client // HTTP client
credentials *Credentials // OnlyOffice credentials
token *Token // Authentication token
defaults Defaults // optional fallbacks for calendar/project IDs
selfID string // cached /api/2.0/people/@self id
noteCatID int // cached CRM history category id for "note"
}
// MetaResponse Response
type MetaResponse struct {
Count int `json:"count"`
Total int `json:"total"`
Status int `json:"status"`
StatusCode int `json:"statusCode"`
}
type Permissions struct {
CanEdit *bool `json:"canEdit,omitempty"` // Is user can edit
CanDelete *bool `json:"canDelete,omitempty"` // Is user can delete
}
// Project struct
type Project struct {
ID *int `json:"id"`
Title *string `json:"title"`
Security map[string]bool `json:"security,omitempty"`
ProjectFolder *json.Number `json:"projectFolder,omitempty"`
Description *string `json:"description"`
Status *int `json:"status"`
ResponsibleID *string `json:"responsibleId,omitempty"`
Responsible *User `json:"responsible,omitempty"`
IsPrivate *bool `json:"isPrivate"`
TaskCount *int `json:"taskCount,omitempty"`
TaskCountTotal *int `json:"taskCountTotal,omitempty"`
MilestoneCount *int `json:"milestoneCount,omitempty"`
DiscussionCount *int `json:"discussionCount,omitempty"`
ParticipantCount *int `json:"participantCount,omitempty"`
TimeTrackingTotal *string `json:"timeTrackingTotal,omitempty"`
DocumentsCount *int `json:"documentsCount,omitempty"`
IsFollow *bool `json:"isFollow,omitempty"`
Created *time.Time `json:"created"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById"`
Updated *time.Time `json:"updated"`
UpdatedByID *string `json:"updatedById"`
Permissions *Permissions `json:",inline,omitempty"`
}
type Milestone struct {
ID *int64 `json:"id,omitempty"`
Description *string `json:"description,omitempty"`
Title *string `json:"title,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
IsKey *bool `json:"isKey,omitempty"`
IsNotify *bool `json:"isNotify,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Responsible *User `json:"responsible,omitempty"`
ActiveTaskCount *int64 `json:"activeTaskCount,omitempty"`
ClosedTaskCount *int64 `json:"closedTaskCount,omitempty"`
Status *int64 `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
*Permissions `json:",inline,omitempty"`
}
type User struct {
ID *string `json:"id,omitempty"`
UserName *string `json:"userName,omitempty"`
IsVisitor *bool `json:"isVisitor,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Email *string `json:"email,omitempty"`
Status *int `json:"status,omitempty"`
ActivationStatus *int `json:"activationStatus,omitempty"`
Terminated any `json:"terminated,omitempty"`
Department *string `json:"department,omitempty"`
WorkFrom *time.Time `json:"workFrom,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
AvatarMedium *string `json:"avatarMedium,omitempty"`
Avatar *string `json:"avatar,omitempty"`
IsAdmin *bool `json:"isAdmin,omitempty"`
IsLDAP *bool `json:"isLDAP,omitempty"`
ListAdminModules []string `json:"listAdminModules,omitempty"`
IsOwner *bool `json:"isOwner,omitempty"`
CultureName *string `json:"cultureName,omitempty"`
IsSSO *bool `json:"isSSO,omitempty"`
AvatarSmall *string `json:"avatarSmall,omitempty"`
QuotaLimit *int `json:"quotaLimit,omitempty"`
UsedSpace *int `json:"usedSpace,omitempty"`
DocsSpace *int `json:"docsSpace,omitempty"`
MailSpace *int `json:"mailSpace,omitempty"`
TalkSpace *int `json:"talkSpace,omitempty"`
ProfileURL *string `json:"profileUrl,omitempty"`
Title *string `json:"title,omitempty"`
Sex *string `json:"sex,omitempty"`
Lead *string `json:"lead,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
Location *string `json:"location,omitempty"`
Notes *string `json:"notes,omitempty"`
Contacts []Contact `json:"contacts,omitempty"`
Groups []Group `json:"groups,omitempty"`
}
type Contact struct {
Type *string `json:"type,omitempty"`
Value *string `json:"value,omitempty"`
}
type Group struct {
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
Manager interface{} `json:"manager,omitempty"`
}
type Task struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Subtasks []any `json:"subtasks,omitempty"`
Status *ProjectTaskStatus `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById,omitempty"` // UUID
Updated *time.Time `json:"updated,omitempty"`
UpdatedBy *User `json:"updatedBy,omitempty"`
UpdatedById *string `json:"updatedById,omitempty"` // UUID
Responsibles []*User `json:"responsibles,omitempty"`
ResponsibleIDS []string `json:"responsibleIds,omitempty"` // UUID list
CanEdit *bool `json:"canEdit,omitempty"`
CanCreateSubtask *bool `json:"canCreateSubtask,omitempty"`
CanCreateTimeSpend *bool `json:"canCreateTimeSpend,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
CanReadFiles *bool `json:"canReadFiles,omitempty"`
MilestoneID *int64 `json:"milestoneId,omitempty"`
Milestone *Milestone `json:"milestone,omitempty"`
}
var GiteaIssue2OnlyOfficeMappingRegExp = regexp.MustCompile(`URL:(.*)$`)
var TestReg = `
URL:https://git.markets-platform.com/TradePlatform/email-templates/issues/1`
// GetGiteaIssueLink from task description
func (t *Task) GetGiteaIssueLink() string {
// Get issue from description
if t.Description != nil {
var match = GiteaIssue2OnlyOfficeMappingRegExp.FindStringSubmatch(*t.Description)
if len(match) > 1 {
return strings.TrimSpace(match[1])
}
}
return ""
}
// TaskPriority High = 1, Normal = 0, Low = -1
type TaskPriority int
const (
TaskPriorityHigh TaskPriority = 1 // High
TaskPriorityNormal TaskPriority = 0 // Normal
TaskPriorityLow TaskPriority = -1
)
type ProjectOwner struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Status *int `json:"status,omitempty"`
IsPrivate *bool `json:"isPrivate,omitempty"`
}
// String for Project to return title
func (p Project) String() string {
if p.Title == nil {
return ""
}
return *p.Title
}
// Token OnlyOffice
type Token struct {
Value string `json:"token"`
Expires Time `json:"expires"`
}
// Time for OnlyOffice
type Time time.Time
// To String for Time
func (t Time) String() string {
return time.Time(t).Format("2006-01-02T15:04:05")
}
func (t Time) Before(u Time) bool {
return time.Time(t).Before(time.Time(u))
}
func (t Time) After(u Time) bool {
return time.Time(t).After(time.Time(u))
}
// UnmarshalJSON for Time
func (r *Time) UnmarshalJSON(data []byte) error {
// Trim quotes
data = data[1 : len(data)-1]
t, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", string(data))
if err != nil {
return err
}
*r = Time(t)
return nil
}
// Request for OnlyOffice API
type Request struct {
Uri string // URI is the path to the API endpoint e.g. /api/2.0/project.json
Method string // Method is the HTTP method e.g. GET, POST, PUT, DELETE
Params any // Params is the query parameters
Body any // Body could be a struct, map, []byte, or string. It will be marshaled to JSON
Token *string // Token is the Authorization header, if not set then it will get a token
NoAuth bool // NoAuth for skipping automatic authentication
Debug bool
}
// GetMethod for Request
func (r Request) GetMethod() string {
if r.Method == "" {
return "GET"
}
return r.Method
}
// Query the OnlyOffice API
// - If request.Method is not set then it will default to GET
// - If request.Body is not nil then it will be marshaled to JSON (unless already []byte/string)
// - If request.Token is nil then it will get (or reuse) a cached token
// - If request.Token is not nil then it will be used as Authorization header
// - If request.NoAuth is true then it will skip automatic authentication
//
// The Debug field is preserved for backwards compatibility; it no longer
// changes behaviour — both branches used to unmarshal into the same target.
func (c *Client) Query(request Request, result interface{}) error {
url := c.credentials.Url + request.Uri
if request.Params != nil {
v, err := query.Values(request.Params)
if err != nil {
return err
}
url = fmt.Sprintf("%s?%s", url, v.Encode())
}
rdr, err := requestBodyReader(request.Body)
if err != nil {
return err
}
req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache")
if !request.NoAuth {
if err := c.ensureToken(); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
}
switch {
case request.Token != nil:
req.Header.Set("Authorization", *request.Token)
case c.token != nil:
req.Header.Set("Authorization", c.token.Value)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if result == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(result)
}
// requestBodyReader normalises Query() body input into an io.Reader.
// []byte and string are passed through verbatim; everything else is marshalled
// to JSON. Returns (nil, nil) for a nil body.
func requestBodyReader(body any) (io.Reader, error) {
if body == nil {
return nil, nil
}
switch b := body.(type) {
case []byte:
return bytes.NewReader(b), nil
case string:
return strings.NewReader(b), nil
default:
j, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
return bytes.NewReader(j), nil
}
}
// Auth to authenticate by getting a token using credentials
func (c *Client) Auth(creds *Credentials) (t *Token, err error) {
t = &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: "POST",
Body: creds,
NoAuth: true,
}, &struct { // Unmarshal response into a struct
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Projects list
type Projects []*Project
// Get Project by title
func (p Projects) Get(title string) *Project {
for _, prj := range p {
if *prj.Title == title {
return prj
}
}
return nil
}
// GetProjects
// - Get all projects, not excluding private projects
func (c *Client) GetProjects() (list Projects, err error) {
return list, c.Query(Request{Uri: `/api/2.0/project/filter.json?simple=true`},
&struct {
MetaResponse `json:",inline"`
Response *Projects
}{Response: &list})
}
// GetProjectMilestones Get project milestones
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID)},
&struct {
MetaResponse `json:",inline"`
//Response *[]*Milestone
Response *[]*Milestone
}{Response: &list})
return list, err
}
// GetUsers List users
func (c *Client) GetUsers() (list []*User, err error) {
return list, c.Query(Request{Uri: "/api/2.0/people/filter.json"},
&struct {
MetaResponse `json:",inline"`
Response *[]*User `json:"response"`
}{Response: &list})
}
// NewProjectRequest
type NewProjectRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ResponsibleID string `json:"responsibleId"`
}
// ProjectUpdateRequest
type ProjectUpdateRequest struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ResponsibleID string `json:"responsibleId,omitempty"`
}
// NewProjectTaskRequest
type NewProjectTaskRequest struct {
Title string `url:"title"`
Description string `url:"description"`
Notify bool `url:"notify"`
MilestoneId int `url:"milestoneId"`
Priority int `url:"priority"`
ProjectId int `url:"projectId"`
StartDate Time `url:"startDate"` // 2024-04-16T00:00:00
Deadline Time `url:"deadline"` // 2024-04-20T00:00:00
Status ProjectTaskStatus
}
type ProjectTaskStatus int
const (
ProjectTaskStatusNotAccept ProjectTaskStatus = 0
ProjectTaskStatusOpen ProjectTaskStatus = 1
ProjectTaskStatusClosed ProjectTaskStatus = 2
ProjectTaskStatusDisable ProjectTaskStatus = 3
ProjectTaskStatusUnclassified ProjectTaskStatus = 4
ProjectTaskStatusNotInMilestone ProjectTaskStatus = 5
)
// ProjectTaskUpdateRequest - Update a task
// See: https://api1.onlyoffice.com/portals/method/project/put/api/2.0/project/task/%7btaskid%7d
type ProjectTaskUpdateRequest struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"` // optional, New task priority {"0"} High = 1, Normal = 0, Low = -1
StartDate *Time `json:"startDate,omitempty"`
Deadline *Time `json:"deadline,omitempty"`
//Deadline *Time `json:"closed,omitempty"`
ProjectID *int64 `json:"projectID,omitempty"` // New task project ID (optional)
MilestoneId *int64 `json:"milestoneid,omitempty"`
Responsible []string `json:"responsibles,omitempty"` // New list of task responsibles {"9924256A-739C-462b-AF15-E652A3B1B6EB"}
Notify bool `json:"notify,omitempty"`
Status ProjectTaskStatus `json:"status,omitempty"`
//Progress int64 `json:"progress,omitempty"` // New task progress
}
// MarshalJSON to quote Time as string
func (t Time) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(
`"%s"`,
time.Time(t).Format("2006-01-02T15:04:05"),
)), nil
}
// CreateProject with a new project request
// - if responsibleID is empty then it will use the first user as responsible
// - if responsibleID is empty and user is not found then it will use the current user
func (c *Client) CreateProject(np NewProjectRequest) (prj *Project, err error) {
// We need a responsible user
if np.ResponsibleID == "" {
var users []*User
users, err = c.GetUsers()
if err != nil {
return
}
// Find user by email
for _, user := range users {
if *user.Email == c.credentials.User {
np.ResponsibleID = *user.ID
break
}
}
// Has no user?
if np.ResponsibleID == "" {
// Set first user as responsible
np.ResponsibleID = *users[0].ID
}
}
prj = new(Project)
return prj, c.Query(Request{
Uri: "/api/2.0/project.json",
Method: "POST",
Body: np,
}, &struct {
MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{
Response: prj,
})
}
func (c *Client) CreateProjectTask(req NewProjectTaskRequest) (task *Task, err error) {
task = &Task{}
return task, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/task.json", req.ProjectId),
Method: "POST",
Body: req,
}, &struct {
//MetaResponse `json:",inline"`
Response *Task `json:"response"`
}{task})
}
func (c *Client) DeleteProject(id int) (p *Project, err error) {
p = &Project{}
return p, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", id),
Method: "DELETE"},
&struct {
//MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{p})
}
func (c *Client) UpdateProject(req ProjectUpdateRequest) (p *Project, err error) {
p = &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
Method: "PUT",
Body: req,
},
&struct {
//MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{p})
}
func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (task *Task, err error) {
task = &Task{}
return task, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT",
Body: req,
}, &struct {
Response *Task `json:"response"`
}{task})
}
// ProjectGetTasksRequest
// See: https://api.onlyoffice.com/workspace/api-backend/usage-api/project/tasks/get-tasks-by-status/
type ProjectGetTasksRequest struct {
ProjectId int `url:"projectId"`
Count int `url:"count"`
StartIndex int `url:"startIndex"`
SortBy string `url:"sortBy"`
SortOrder string `url:"sortOrder"`
Simple bool `url:"simple"`
}
// NewProjectGetTasksRequest to get a simple task list
func NewProjectGetTasksRequest(projectId int) ProjectGetTasksRequest {
return ProjectGetTasksRequest{
ProjectId: projectId,
Count: 1000,
StartIndex: 0,
SortBy: "title",
SortOrder: "ascending",
Simple: true,
}
}
// GetTasks returns a list of all the tasks from a project with the ID specified in the request.
func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error) {
return tasks, c.Query(
Request{
Uri: "/api/2.0/project/task/filter.json",
Params: req,
},
&struct {
Response *[]*Task `json:"response"`
}{&tasks})
}
+206
View File
@@ -0,0 +1,206 @@
package onlyoffice
// Project / Milestone typed API.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"time"
)
// Project struct
type Project struct {
ID *int `json:"id"`
Title *string `json:"title"`
Security map[string]bool `json:"security,omitempty"`
ProjectFolder *json.Number `json:"projectFolder,omitempty"`
Description *string `json:"description"`
Status *int `json:"status"`
ResponsibleID *string `json:"responsibleId,omitempty"`
Responsible *User `json:"responsible,omitempty"`
IsPrivate *bool `json:"isPrivate"`
TaskCount *int `json:"taskCount,omitempty"`
TaskCountTotal *int `json:"taskCountTotal,omitempty"`
MilestoneCount *int `json:"milestoneCount,omitempty"`
DiscussionCount *int `json:"discussionCount,omitempty"`
ParticipantCount *int `json:"participantCount,omitempty"`
TimeTrackingTotal *string `json:"timeTrackingTotal,omitempty"`
DocumentsCount *int `json:"documentsCount,omitempty"`
IsFollow *bool `json:"isFollow,omitempty"`
Created *time.Time `json:"created"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById"`
Updated *time.Time `json:"updated"`
UpdatedByID *string `json:"updatedById"`
Permissions *Permissions `json:",inline,omitempty"`
}
// String returns the project Title, or "" if the Title is nil.
func (p Project) String() string {
if p.Title == nil {
return ""
}
return *p.Title
}
// Projects is a slice with helpers for title lookup.
type Projects []*Project
// Get returns the first project whose title equals title, or nil.
func (p Projects) Get(title string) *Project {
for _, prj := range p {
if prj != nil && prj.Title != nil && *prj.Title == title {
return prj
}
}
return nil
}
// Milestone is a project milestone.
type Milestone struct {
ID *int64 `json:"id,omitempty"`
Description *string `json:"description,omitempty"`
Title *string `json:"title,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
IsKey *bool `json:"isKey,omitempty"`
IsNotify *bool `json:"isNotify,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Responsible *User `json:"responsible,omitempty"`
ActiveTaskCount *int64 `json:"activeTaskCount,omitempty"`
ClosedTaskCount *int64 `json:"closedTaskCount,omitempty"`
Status *int64 `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
*Permissions `json:",inline,omitempty"`
}
// ProjectOwner is a compact project reference used by Milestone/Task.
type ProjectOwner struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Status *int `json:"status,omitempty"`
IsPrivate *bool `json:"isPrivate,omitempty"`
}
// NewProjectRequest is the payload for CreateProject.
type NewProjectRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ResponsibleID string `json:"responsibleId"`
}
// ProjectUpdateRequest is the payload for UpdateProject. Only non-empty
// fields are transmitted (enforced by omitempty).
type ProjectUpdateRequest struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ResponsibleID string `json:"responsibleId,omitempty"`
}
// GetProjects returns all projects, including private ones the caller can see.
func (c *Client) GetProjects() (list Projects, err error) {
return list, c.Query(Request{Uri: `/api/2.0/project/filter.json?simple=true`},
&struct {
MetaResponse `json:",inline"`
Response *Projects
}{Response: &list})
}
// GetProjectByID returns a single project as an untyped map. The typed
// counterpart is not currently provided; callers can iterate GetProjects and
// match by title or write their own typed wrapper.
//
// When projectID is empty the configured default is used.
func (c *Client) GetProjectByID(ctx context.Context, projectID string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID)))
}
// GetProjectMilestones returns milestones for the given project.
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID)},
&struct {
MetaResponse `json:",inline"`
Response *[]*Milestone
}{Response: &list})
return list, err
}
// CreateProject creates a new project.
// - if ResponsibleID is empty, the first user matching the client's User
// email is picked; failing that, the first portal user.
func (c *Client) CreateProject(np NewProjectRequest) (*Project, error) {
if np.ResponsibleID == "" {
users, err := c.GetUsers()
if err != nil {
return nil, err
}
for _, u := range users {
if u.Email != nil && *u.Email == c.credentials.User {
np.ResponsibleID = *u.ID
break
}
}
if np.ResponsibleID == "" && len(users) > 0 && users[0].ID != nil {
np.ResponsibleID = *users[0].ID
}
}
prj := new(Project)
return prj, c.Query(Request{
Uri: "/api/2.0/project.json",
Method: "POST",
Body: np,
}, &struct {
MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{
Response: prj,
})
}
// DeleteProject deletes a project by numeric ID.
func (c *Client) DeleteProject(id int) (*Project, error) {
p := &Project{}
return p, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", id),
Method: "DELETE",
},
&struct {
Response *Project `json:"response"`
}{p})
}
// UpdateProject updates project fields.
func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
p := &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
Method: "PUT",
Body: req,
},
&struct {
Response *Project `json:"response"`
}{p})
}
+188
View File
@@ -0,0 +1,188 @@
package onlyoffice
// Low-level Request/Query primitive used by the typed helpers in this
// package. Prefer the domain-specific methods (CreateProject, GetTasks, …)
// or the untyped helpers in http.go (ResponseArray, ResponseObject, …) — this
// file is kept for API compatibility and for rare callers that want to hit
// arbitrary endpoints with arbitrary Params/Body.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/google/go-querystring/query"
)
// Request for OnlyOffice API.
type Request struct {
Uri string // URI is the path to the API endpoint e.g. /api/2.0/project.json
Method string // HTTP method; defaults to GET when empty.
Params any // Params is serialised to the query string via go-querystring.
Body any // Body is marshalled to JSON unless it is []byte or string.
Token *string // Explicit Authorization header; overrides the cached token.
NoAuth bool // Skip automatic authentication.
Debug bool // Kept for backwards compatibility; no longer affects behaviour.
}
// GetMethod returns Method, defaulting to GET when unset.
func (r Request) GetMethod() string {
if r.Method == "" {
return http.MethodGet
}
return r.Method
}
// Query the OnlyOffice API.
//
// - If request.Method is empty it defaults to GET.
// - If request.Body is non-nil it is marshalled to JSON (unless it is
// already []byte or string, which are passed through verbatim).
// - If request.Token is nil the cached session token is used (and
// refreshed as needed unless NoAuth is set).
// - If request.Token is non-nil it is used verbatim as the Authorization
// header.
// - If request.NoAuth is true then no token is fetched — the caller is
// responsible for authenticating requests (used internally by Auth()).
func (c *Client) Query(request Request, result interface{}) error {
url := c.credentials.Url + request.Uri
if request.Params != nil {
v, err := query.Values(request.Params)
if err != nil {
return err
}
url = fmt.Sprintf("%s?%s", url, v.Encode())
}
rdr, err := requestBodyReader(request.Body)
if err != nil {
return err
}
req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache")
if !request.NoAuth {
if err := c.ensureToken(); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
}
switch {
case request.Token != nil:
req.Header.Set("Authorization", *request.Token)
case c.token != nil:
req.Header.Set("Authorization", c.token.Value)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if result == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(result)
}
// requestBodyReader normalises Query() body input into an io.Reader.
// []byte and string are passed through verbatim; everything else is
// marshalled to JSON. Returns (nil, nil) for a nil body.
func requestBodyReader(body any) (io.Reader, error) {
if body == nil {
return nil, nil
}
switch b := body.(type) {
case []byte:
return bytes.NewReader(b), nil
case string:
return strings.NewReader(b), nil
default:
j, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
return bytes.NewReader(j), nil
}
}
// ToJson for Credentials returns a JSON-encoded payload suitable for the
// authentication endpoint. Returns nil on marshal error (which never happens
// for this struct).
func (c Credentials) ToJson() []byte {
b, err := json.Marshal(c)
if err != nil {
return nil
}
return b
}
// MetaResponse is the shape of every envelope returned by the OnlyOffice API.
// It is embedded inline into the per-endpoint response structs via the
// `json:",inline"` convention used throughout this package.
type MetaResponse struct {
Count int `json:"count"`
Total int `json:"total"`
Status int `json:"status"`
StatusCode int `json:"statusCode"`
}
// Permissions is a common subset of boolean permissions embedded into
// several entity types.
type Permissions struct {
CanEdit *bool `json:"canEdit,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
}
// Token is the authentication token returned by /api/2.0/authentication.json.
type Token struct {
Value string `json:"token"`
Expires Time `json:"expires"`
}
// Time wraps time.Time with the OnlyOffice "yyyy-MM-ddTHH:mm:ss.fffffffzzz"
// wire format used by several endpoints.
type Time time.Time
// String formats the time in OnlyOffice's expected ISO-8601 variant (without
// fractional seconds or timezone).
func (t Time) String() string {
return time.Time(t).Format("2006-01-02T15:04:05")
}
// Before reports whether t is before u.
func (t Time) Before(u Time) bool {
return time.Time(t).Before(time.Time(u))
}
// After reports whether t is after u.
func (t Time) After(u Time) bool {
return time.Time(t).After(time.Time(u))
}
// UnmarshalJSON decodes the OnlyOffice wire format into Time.
func (r *Time) UnmarshalJSON(data []byte) error {
data = data[1 : len(data)-1] // trim surrounding quotes
t, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", string(data))
if err != nil {
return err
}
*r = Time(t)
return nil
}
// MarshalJSON emits the OnlyOffice-friendly short form.
func (t Time) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`"%s"`, time.Time(t).Format("2006-01-02T15:04:05"))), nil
}
+275
View File
@@ -0,0 +1,275 @@
package onlyoffice
// Project task API — both typed (Task + CreateProjectTask / UpdateProjectTask
// / GetTasks) and untyped form-endpoint helpers (AddTask, AddSubtask,
// UpdateTaskStatus, DeleteTask, ListTasks, …). The typed path mirrors the
// JSON responses; the form-encoded path mirrors the Python cv/bin/office
// reference and the OnlyOffice web UI.
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// Task is the JSON-mapped project task as returned by /project/task/*.
type Task struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Subtasks []any `json:"subtasks,omitempty"`
Status *ProjectTaskStatus `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById,omitempty"` // UUID
Updated *time.Time `json:"updated,omitempty"`
UpdatedBy *User `json:"updatedBy,omitempty"`
UpdatedById *string `json:"updatedById,omitempty"` // UUID
Responsibles []*User `json:"responsibles,omitempty"`
ResponsibleIDS []string `json:"responsibleIds,omitempty"` // UUID list
CanEdit *bool `json:"canEdit,omitempty"`
CanCreateSubtask *bool `json:"canCreateSubtask,omitempty"`
CanCreateTimeSpend *bool `json:"canCreateTimeSpend,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
CanReadFiles *bool `json:"canReadFiles,omitempty"`
MilestoneID *int64 `json:"milestoneId,omitempty"`
Milestone *Milestone `json:"milestone,omitempty"`
}
// TaskPriority values: High = 1, Normal = 0, Low = -1.
type TaskPriority int
const (
TaskPriorityHigh TaskPriority = 1
TaskPriorityNormal TaskPriority = 0
TaskPriorityLow TaskPriority = -1
)
// ProjectTaskStatus encodes OnlyOffice task status codes.
type ProjectTaskStatus int
const (
ProjectTaskStatusNotAccept ProjectTaskStatus = 0
ProjectTaskStatusOpen ProjectTaskStatus = 1
ProjectTaskStatusClosed ProjectTaskStatus = 2
ProjectTaskStatusDisable ProjectTaskStatus = 3
ProjectTaskStatusUnclassified ProjectTaskStatus = 4
ProjectTaskStatusNotInMilestone ProjectTaskStatus = 5
)
// GiteaIssue2OnlyOfficeMappingRegExp extracts a "URL:" footer pointing at a
// Gitea issue, used by external sync tooling (inventar-sync et al.).
var GiteaIssue2OnlyOfficeMappingRegExp = regexp.MustCompile(`URL:(.*)$`)
// GetGiteaIssueLink returns the first Gitea URL embedded in the task
// description via the "URL:<url>" convention, or "" if absent.
func (t *Task) GetGiteaIssueLink() string {
if t.Description == nil {
return ""
}
m := GiteaIssue2OnlyOfficeMappingRegExp.FindStringSubmatch(*t.Description)
if len(m) > 1 {
return strings.TrimSpace(m[1])
}
return ""
}
// NewProjectTaskRequest creates a new task via the typed JSON API.
type NewProjectTaskRequest struct {
Title string `url:"title"`
Description string `url:"description"`
Notify bool `url:"notify"`
MilestoneId int `url:"milestoneId"`
Priority int `url:"priority"`
ProjectId int `url:"projectId"`
StartDate Time `url:"startDate"`
Deadline Time `url:"deadline"`
Status ProjectTaskStatus
}
// ProjectTaskUpdateRequest updates an existing task.
// https://api1.onlyoffice.com/portals/method/project/put/api/2.0/project/task/%7btaskid%7d
type ProjectTaskUpdateRequest struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
StartDate *Time `json:"startDate,omitempty"`
Deadline *Time `json:"deadline,omitempty"`
ProjectID *int64 `json:"projectID,omitempty"`
MilestoneId *int64 `json:"milestoneid,omitempty"`
Responsible []string `json:"responsibles,omitempty"` // UUID list
Notify bool `json:"notify,omitempty"`
Status ProjectTaskStatus `json:"status,omitempty"`
}
// ProjectGetTasksRequest is the filter payload for GetTasks.
// See https://api.onlyoffice.com/workspace/api-backend/usage-api/project/tasks/get-tasks-by-status/
type ProjectGetTasksRequest struct {
ProjectId int `url:"projectId"`
Count int `url:"count"`
StartIndex int `url:"startIndex"`
SortBy string `url:"sortBy"`
SortOrder string `url:"sortOrder"`
Simple bool `url:"simple"`
}
// NewProjectGetTasksRequest builds a simple "all tasks, sorted by title"
// request pre-populated with sane defaults (count=1000).
func NewProjectGetTasksRequest(projectId int) ProjectGetTasksRequest {
return ProjectGetTasksRequest{
ProjectId: projectId,
Count: 1000,
StartIndex: 0,
SortBy: "title",
SortOrder: "ascending",
Simple: true,
}
}
// CreateProjectTask creates a project task via the typed JSON API.
func (c *Client) CreateProjectTask(req NewProjectTaskRequest) (*Task, error) {
task := &Task{}
return task, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/task.json", req.ProjectId),
Method: "POST",
Body: req,
}, &struct {
Response *Task `json:"response"`
}{task})
}
// UpdateProjectTask updates task fields via the typed JSON API.
func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (*Task, error) {
task := &Task{}
return task, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT",
Body: req,
}, &struct {
Response *Task `json:"response"`
}{task})
}
// GetTasks returns a list of tasks for a project matching the given filter.
func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error) {
return tasks, c.Query(
Request{
Uri: "/api/2.0/project/task/filter.json",
Params: req,
},
&struct {
Response *[]*Task `json:"response"`
}{&tasks})
}
// -----------------------------------------------------------------------------
// Untyped form-endpoint helpers (Python cv/bin/office parity, web UI parity)
// -----------------------------------------------------------------------------
// ListTasks lists tasks in a single project. When projectID is empty the
// configured default is used. status accepts "open"/"closed" or a numeric
// code.
func (c *Client) ListTasks(ctx context.Context, projectID, status string) ([]map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
if projectID == "" {
return nil, fmt.Errorf("ListTasks: projectID is required (pass explicitly or set via SetDefaults)")
}
path := fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID))
if code := taskStatusCode(status); code != "" {
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// ListAllTasks lists tasks across projects for the authenticated user.
func (c *Client) ListAllTasks(ctx context.Context, status string) ([]map[string]any, error) {
path := "/api/2.0/project/task/@self.json"
if code := taskStatusCode(status); code != "" {
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// taskStatusCode maps "open"/"closed" to their numeric codes, passes any
// other non-empty value through verbatim, and returns "" for an empty input.
func taskStatusCode(status string) string {
switch status {
case "":
return ""
case "open":
return "0"
case "closed":
return "1"
default:
return status
}
}
// GetTaskByID returns a task as an untyped map, including its subtasks.
func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any, error) {
return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
}
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
// Prefer the typed CreateProjectTask for new code; AddTask is kept for
// parity with the Python reference tooling.
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("description", description)
fields.Set("priority", strconv.Itoa(priority))
if deadline != "" {
fields.Set("deadline", deadline)
}
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
}
// AddSubtask creates a subtask under parentTaskID.
func (c *Client) AddSubtask(ctx context.Context, parentTaskID, title string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(parentTaskID)), fields)
}
// UpdateTaskStatus changes task status. status accepts "open"/"closed"
// (mapped to 1/2) or a raw numeric code passed through.
func (c *Client) UpdateTaskStatus(ctx context.Context, taskID, status string) (map[string]any, error) {
code := status
switch status {
case "open":
code = "1"
case "closed":
code = "2"
}
fields := url.Values{}
fields.Set("status", code)
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s/status.json", url.PathEscape(taskID)), fields)
}
// DeleteTask removes a project task by ID.
func (c *Client) DeleteTask(ctx context.Context, taskID string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
}
-169
View File
@@ -1,169 +0,0 @@
package onlyoffice
// Task-related helpers that complement the typed Project/Task API in
// onlyoffice.go. These return untyped maps and use form-encoded endpoints,
// matching the Python cv/bin/office reference and the OnlyOffice web UI.
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
// GetProjectByID returns a single project as an untyped map.
// (The typed counterpart GetProject is not provided; callers that want typed
// structs should use Projects + iteration or add their own typed wrappers.)
func (c *Client) GetProjectByID(ctx context.Context, projectID string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/project/%s.json", url.PathEscape(projectID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// ListTasks lists tasks in a single project. When projectID is empty the
// configured default is used. status accepts "open"/"closed" or a numeric code.
func (c *Client) ListTasks(ctx context.Context, projectID, status string) ([]map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
if projectID == "" {
return nil, fmt.Errorf("ListTasks: projectID is required (pass explicitly or set via SetDefaults)")
}
path := fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID))
if status != "" {
code := map[string]string{"open": "0", "closed": "1"}[status]
if code == "" {
code = status
}
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// ListAllTasks lists tasks across projects for the authenticated user.
func (c *Client) ListAllTasks(ctx context.Context, status string) ([]map[string]any, error) {
path := "/api/2.0/project/task/@self.json"
if status != "" {
code := map[string]string{"open": "0", "closed": "1"}[status]
if code == "" {
code = status
}
path += "?status=" + url.QueryEscape(code)
}
return c.ResponseArray(ctx, path)
}
// GetTaskByID returns a task as an untyped map, including its subtasks.
func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any, error) {
raw, err := c.getJSON(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// AddTask creates a task via the form-encoded endpoint (no milestone/start).
// Prefer the typed CreateProjectTask for new code; AddTask is kept for parity
// with the Python reference tooling.
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("description", description)
fields.Set("priority", fmt.Sprintf("%d", priority))
if deadline != "" {
fields.Set("deadline", deadline)
}
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// AddSubtask creates a subtask under parentTaskID.
func (c *Client) AddSubtask(ctx context.Context, parentTaskID, title string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(parentTaskID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return out, nil
}
// UpdateTaskStatus changes task status. status accepts "open"/"closed" (mapped
// to 1/2) or a raw numeric code passed through.
func (c *Client) UpdateTaskStatus(ctx context.Context, taskID, status string) (map[string]any, error) {
code := map[string]string{"open": "1", "closed": "2"}[status]
if code == "" {
code = status
}
fields := url.Values{}
fields.Set("status", code)
raw, err := c.putForm(ctx, fmt.Sprintf("/api/2.0/project/task/%s/status.json", url.PathEscape(taskID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
}
// DeleteTask removes a project task by ID.
func (c *Client) DeleteTask(ctx context.Context, taskID string) (map[string]any, error) {
raw, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/project/task/%s.json", url.PathEscape(taskID)))
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var out map[string]any
_ = json.Unmarshal(resp, &out)
return out, nil
}
+93
View File
@@ -0,0 +1,93 @@
package onlyoffice
// User / People endpoints and associated entity types.
import (
"context"
"encoding/json"
"time"
)
// User represents an OnlyOffice portal user. Fields reflect the full
// /api/2.0/people/filter.json response; most are optional and returned
// only in user-detail responses.
type User struct {
ID *string `json:"id,omitempty"`
UserName *string `json:"userName,omitempty"`
IsVisitor *bool `json:"isVisitor,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Email *string `json:"email,omitempty"`
Status *int `json:"status,omitempty"`
ActivationStatus *int `json:"activationStatus,omitempty"`
Terminated any `json:"terminated,omitempty"`
Department *string `json:"department,omitempty"`
WorkFrom *time.Time `json:"workFrom,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
AvatarMedium *string `json:"avatarMedium,omitempty"`
Avatar *string `json:"avatar,omitempty"`
IsAdmin *bool `json:"isAdmin,omitempty"`
IsLDAP *bool `json:"isLDAP,omitempty"`
ListAdminModules []string `json:"listAdminModules,omitempty"`
IsOwner *bool `json:"isOwner,omitempty"`
CultureName *string `json:"cultureName,omitempty"`
IsSSO *bool `json:"isSSO,omitempty"`
AvatarSmall *string `json:"avatarSmall,omitempty"`
QuotaLimit *int `json:"quotaLimit,omitempty"`
UsedSpace *int `json:"usedSpace,omitempty"`
DocsSpace *int `json:"docsSpace,omitempty"`
MailSpace *int `json:"mailSpace,omitempty"`
TalkSpace *int `json:"talkSpace,omitempty"`
ProfileURL *string `json:"profileUrl,omitempty"`
Title *string `json:"title,omitempty"`
Sex *string `json:"sex,omitempty"`
Lead *string `json:"lead,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
Location *string `json:"location,omitempty"`
Notes *string `json:"notes,omitempty"`
Contacts []Contact `json:"contacts,omitempty"`
Groups []Group `json:"groups,omitempty"`
}
// Contact is a typed contact entry attached to a User.
type Contact struct {
Type *string `json:"type,omitempty"`
Value *string `json:"value,omitempty"`
}
// Group is a portal user group.
type Group struct {
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
Manager any `json:"manager,omitempty"`
}
// GetUsers lists all portal users.
func (c *Client) GetUsers() (list []*User, err error) {
return list, c.Query(Request{Uri: "/api/2.0/people/filter.json"},
&struct {
MetaResponse `json:",inline"`
Response *[]*User `json:"response"`
}{Response: &list})
}
// SelfUserID returns the ID of the authenticated user (people/@self), cached.
func (c *Client) SelfUserID(ctx context.Context) (string, error) {
if c.selfID != "" {
return c.selfID, nil
}
raw, err := c.getJSON(ctx, "/api/2.0/people/@self.json")
if err != nil {
return "", err
}
var env struct {
Response struct {
ID string `json:"id"`
} `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", err
}
c.selfID = env.Response.ID
return c.selfID, nil
}