From 09739754715c3661c05213bba8591012f8ef7a34 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 24 Apr 2026 14:55:21 +0100 Subject: [PATCH] =?UTF-8?q?refactor!:=20rename=20oo-cli=20=E2=86=92=20oo,?= =?UTF-8?q?=20split=20library=20by=20domain,=20relocate=20applications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 +- AGENTS.md | 16 +- CHANGELOG.md | 62 +++ README.md | 23 +- auth.go | 121 +++++ calendar.go | 18 +- client.go | 108 ++++ cmd/oo-cli/README.md | 47 -- cmd/oo-cli/main.go | 20 - cmd/oo/calendar.go | 101 ++++ cmd/oo/common.go | 73 +++ cmd/oo/crm.go | 181 +++++++ cmd/oo/main.go | 24 + cmd/oo/tasks.go | 140 ++++++ crm.go | 227 ++------- examples/applications/main.go | 54 -- files.go | 11 +- httpx.go => http.go | 173 +++---- internal/applications/applications.go | 430 ---------------- internal/applications/parse_test.go | 53 -- internal/cli/root.go | 517 ------------------- onlyoffice.go | 686 -------------------------- projects.go | 206 ++++++++ request.go | 188 +++++++ tasks.go | 275 +++++++++++ tasks_extra.go | 169 ------- users.go | 93 ++++ 27 files changed, 1704 insertions(+), 2315 deletions(-) create mode 100644 auth.go create mode 100644 client.go delete mode 100644 cmd/oo-cli/README.md delete mode 100644 cmd/oo-cli/main.go create mode 100644 cmd/oo/calendar.go create mode 100644 cmd/oo/common.go create mode 100644 cmd/oo/crm.go create mode 100644 cmd/oo/main.go create mode 100644 cmd/oo/tasks.go delete mode 100644 examples/applications/main.go rename httpx.go => http.go (61%) delete mode 100644 internal/applications/applications.go delete mode 100644 internal/applications/parse_test.go delete mode 100644 internal/cli/root.go delete mode 100644 onlyoffice.go create mode 100644 projects.go create mode 100644 request.go create mode 100644 tasks.go delete mode 100644 tasks_extra.go create mode 100644 users.go diff --git a/.gitignore b/.gitignore index c7cc644..08cffde 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 4144307..b69b47d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index f51fa63..fc84615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9752c3b..5b1f91f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..98c0986 --- /dev/null +++ b/auth.go @@ -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 +} diff --git a/calendar.go b/calendar.go index 0c62bfd..b53eda5 100644 --- a/calendar.go +++ b/calendar.go @@ -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))) } diff --git a/client.go b/client.go new file mode 100644 index 0000000..f8805c6 --- /dev/null +++ b/client.go @@ -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 "" +} diff --git a/cmd/oo-cli/README.md b/cmd/oo-cli/README.md deleted file mode 100644 index 6b54ad6..0000000 --- a/cmd/oo-cli/README.md +++ /dev/null @@ -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. diff --git a/cmd/oo-cli/main.go b/cmd/oo-cli/main.go deleted file mode 100644 index 9325f35..0000000 --- a/cmd/oo-cli/main.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/oo/calendar.go b/cmd/oo/calendar.go new file mode 100644 index 0000000..5887121 --- /dev/null +++ b/cmd/oo/calendar.go @@ -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 + }, + } +} diff --git a/cmd/oo/common.go b/cmd/oo/common.go new file mode 100644 index 0000000..7bb3c95 --- /dev/null +++ b/cmd/oo/common.go @@ -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 + } +} diff --git a/cmd/oo/crm.go b/cmd/oo/crm.go new file mode 100644 index 0000000..2b6de45 --- /dev/null +++ b/cmd/oo/crm.go @@ -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 + }, + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go new file mode 100644 index 0000000..704e5bd --- /dev/null +++ b/cmd/oo/main.go @@ -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) + } +} diff --git a/cmd/oo/tasks.go b/cmd/oo/tasks.go new file mode 100644 index 0000000..6cdcfd0 --- /dev/null +++ b/cmd/oo/tasks.go @@ -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 +} diff --git a/crm.go b/crm.go index 340363e..0c3ed4b 100644 --- a/crm.go +++ b/crm.go @@ -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) { diff --git a/examples/applications/main.go b/examples/applications/main.go deleted file mode 100644 index b94bf7f..0000000 --- a/examples/applications/main.go +++ /dev/null @@ -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 ") - 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) -} diff --git a/files.go b/files.go index bdee420..2e5381a 100644 --- a/files.go +++ b/files.go @@ -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) } diff --git a/httpx.go b/http.go similarity index 61% rename from httpx.go rename to http.go index 8727b72..044a42e 100644 --- a/httpx.go +++ b/http.go @@ -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() diff --git a/internal/applications/applications.go b/internal/applications/applications.go deleted file mode 100644 index 34218e0..0000000 --- a/internal/applications/applications.go +++ /dev/null @@ -1,430 +0,0 @@ -// Package applications syncs job application README trees into OnlyOffice CRM -// (ported from cv/bin/office/sync-applications.py). -package applications - -import ( - "context" - "encoding/json" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - onlyoffice "github.com/eslider/go-onlyoffice" -) - -var docExt = map[string]bool{".pdf": true, ".docx": true, ".xlsx": true, ".doc": true, ".xls": true} - -type RecruiterInfo struct { - First, Last, JobTitle, Company, Email, Phone, LinkedIn string -} - -type Data struct { - Path string - Folder string - Position string - Company string - Location string - Salary string - SalaryValue float64 - Contract string - Source string - Link string - Recruiter RecruiterInfo - Summary string - Documents []string -} - -func Discover(base string) ([]string, error) { - var out []string - err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if d.Name() == "pdfs" { - return fs.SkipDir - } - return nil - } - if filepath.Base(path) != "README.md" { - return nil - } - dir := filepath.Dir(path) - if filepath.Clean(dir) == filepath.Clean(base) { - return nil - } - out = append(out, path) - return nil - }) - return out, err -} - -func ParseReadme(path string) (Data, error) { - b, err := os.ReadFile(path) - if err != nil { - return Data{}, err - } - text := string(b) - app := Data{Path: path} - app.Folder = relFolder(path) - app.Position = extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`) - app.Company = extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`) - if app.Company != "" { - app.Company = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(app.Company, "$1") - app.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(app.Company), "") - } - app.Location = extract(text, `(?i)\*\*Location\*\*:\s*(.+?)(?:\s{2,}|\n)`) - app.Salary = extract(text, `(?i)\*\*(?:Salary|Gehalt|Rate)\*\*:\s*(.+?)(?:\s{2,}|\n)`) - app.SalaryValue = parseSalary(app.Salary) - app.Contract = extract(text, `(?i)\*\*Contract\*\*:\s*(.+?)(?:\s{2,}|\n)`) - app.Link = extract(text, `(?i)\*\*Link\*\*:\s*(.+?)(?:\s{2,}|\n)`) - if m := regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`).FindStringSubmatch(app.Link); len(m) > 2 { - app.Link = m[2] - } - app.Source = extract(text, `(?i)\*\*Source\*\*:\s*(.+?)(?:\s{2,}|\n)`) - app.Recruiter = parseRecruiter(text) - if app.Source == "" { - app.Source = filepath.Base(filepath.Dir(filepath.Dir(path))) - } - if app.Company == "" || strings.EqualFold(app.Company, "undisclosed") || app.Company == "?" || strings.EqualFold(app.Company, "tbd") { - parts := strings.Split(filepath.Base(filepath.Dir(path)), "-") - if len(parts) >= 2 { - app.Company = titleWord(parts[0]) - } - } - app.Summary = buildSummary(app, text) - app.Documents = discoverDocs(filepath.Dir(path)) - return app, nil -} - -func relFolder(readmePath string) string { - // Python: path.parent.relative_to(path.parent.parent.parent.parent) — approximate: last 3 parts - p := filepath.Clean(readmePath) - parts := strings.Split(p, string(filepath.Separator)) - if len(parts) >= 3 { - return filepath.Join(parts[len(parts)-3:]...) - } - return filepath.Dir(readmePath) -} - -func extract(text, pat string) string { - re := regexp.MustCompile(pat) - m := re.FindStringSubmatch(text) - if len(m) < 2 { - return "" - } - return strings.TrimSpace(m[1]) -} - -func parseSalary(raw string) float64 { - if raw == "" { - return 0 - } - cleaned := strings.ReplaceAll(strings.ReplaceAll(raw, ",", ""), ".", "") - if m := regexp.MustCompile(`(\d{4,6})`).FindAllStringSubmatch(cleaned, -1); len(m) > 0 { - var maxv int - for _, x := range m { - n, _ := strconv.Atoi(x[1]) - if n > maxv { - maxv = n - } - } - return float64(maxv) - } - if m := regexp.MustCompile(`(?i)(\d{2,4})\s*/\s*day`).FindStringSubmatch(raw); len(m) > 1 { - n, _ := strconv.ParseFloat(m[1], 64) - return n * 220 - } - return 0 -} - -func parseRecruiter(text string) RecruiterInfo { - var r RecruiterInfo - raw := extract(text, `(?i)\*\*(?:Recruiter|Consultant)\*\*:\s*(.+?)(?:\s{2,}|\n)`) - if raw == "" { - return r - } - if m := regexp.MustCompile(`<([^>]+@[^>]+)>`).FindStringSubmatch(raw); len(m) > 1 { - r.Email = strings.TrimSpace(m[1]) - if i := strings.Index(raw, "<"); i >= 0 { - raw = strings.TrimSpace(raw[:i]) - } - raw = strings.TrimRight(raw, " —–-") - } - if m := regexp.MustCompile(`(?i)\s+@\s+(.+)$`).FindStringSubmatch(raw); len(m) > 1 { - r.Company = strings.TrimSpace(m[1]) - if i := strings.Index(raw, m[0]); i >= 0 { - raw = strings.TrimSpace(raw[:i]) - } - } - if m := regexp.MustCompile(`,?\s*\[([^\]]+)\]\([^)]+\)`).FindStringSubmatch(raw); len(m) > 1 { - r.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(m[1]), "") - if i := strings.Index(raw, m[0]); i >= 0 { - raw = strings.TrimSpace(raw[:i]) - } - } - parts := strings.SplitN(raw, ",", 2) - namePart := strings.TrimSpace(parts[0]) - if len(parts) > 1 { - r.JobTitle = strings.TrimSpace(parts[1]) - } - toks := strings.Fields(namePart) - if len(toks) >= 2 { - r.First = toks[0] - r.Last = strings.Join(toks[1:], " ") - } else if len(toks) == 1 { - r.First = toks[0] - } - if m := regexp.MustCompile(`(?i)\*\*?(?:Telefon|Phone)\*\*?:\s*([\d+\-\s]+)`).FindStringSubmatch(text); len(m) > 1 { - r.Phone = strings.TrimSpace(m[1]) - } - if m := regexp.MustCompile(`(?i)\*\*LinkedIn\*\*:\s*(https://[^\s]+)`).FindStringSubmatch(text); len(m) > 1 { - r.LinkedIn = strings.TrimSpace(m[1]) - } - return r -} - -func buildSummary(app Data, text string) string { - var lines []string - lines = append(lines, "Position: "+app.Position) - lines = append(lines, "Company: "+app.Company) - if app.Location != "" { - lines = append(lines, "Location: "+app.Location) - } - if app.Salary != "" { - lines = append(lines, "Salary: "+app.Salary) - } - if app.Contract != "" { - lines = append(lines, "Contract: "+app.Contract) - } - if app.Source != "" { - lines = append(lines, "Source: "+app.Source) - } - if app.Link != "" { - lines = append(lines, "Link: "+app.Link) - } - lines = append(lines, "Folder: "+app.Folder) - if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 { - lines = append(lines, "", "Status:", strings.TrimSpace(m[1])) - } - // RE2 does not support lookahead; consume the trailing delimiter (\n## or EOS) - // with a non-capturing alternation — the captured group 1 stops right before it. - if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?:\n## |$)`).FindStringSubmatch(text); len(m) > 1 { - ft := strings.TrimSpace(m[1]) - if len(ft) > 500 { - ft = ft[:500] + "..." - } - lines = append(lines, "", "Fit Assessment:", ft) - } - return strings.Join(lines, "\n") -} - -func discoverDocs(dir string) []string { - var docs []string - entries, _ := os.ReadDir(dir) - for _, e := range entries { - if e.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(e.Name())) - if docExt[ext] { - docs = append(docs, filepath.Join(dir, e.Name())) - } - } - return docs -} - -type Stats struct { - Companies, Persons, Deals, Tasks, Docs, Notes int -} - -// Sync mirrors Python sync_to_crm. -func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, verbose bool) Stats { - var st Stats - deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05") - const stageInitial = 1 - for _, app := range apps { - dealTitle := app.Position - if app.Company != "" { - dealTitle = app.Position + " @ " + app.Company - } - fmt.Println(strings.Repeat("─", 60)) - fmt.Println(" ", app.Folder) - fmt.Println(" ", dealTitle) - if verbose { - // minimal verbose - if app.Salary != "" { - fmt.Printf(" Salary: %s (parsed: %.0f)\n", app.Salary, app.SalaryValue) - } - } - action := "would create" - if !dryRun { - action = "creating" - } - companyID := 0 - if app.Company != "" { - if !dryRun { - if ex, _ := client.FindCompany(ctx, app.Company); ex != nil { - companyID = int(flexID(ex["id"])) - fmt.Printf(" [company] found: [%d] %s\n", companyID, app.Company) - } else { - co, err := client.CreateCompany(ctx, app.Company) - if err != nil { - fmt.Printf(" [company] ERR: %v\n", err) - } else { - companyID = int(flexID(co["id"])) - fmt.Printf(" [company] created: [%d] %s\n", companyID, app.Company) - if app.Link != "" { - _, _ = client.AddContactInfo(ctx, strconv.Itoa(companyID), "Website", app.Link, "Work", false) - } - } - } - } else { - fmt.Printf(" [company] %s: %s\n", action, app.Company) - } - st.Companies++ - } - personID := 0 - r := app.Recruiter - if r.First != "" && r.Last != "" { - if !dryRun { - if ex, _ := client.FindPerson(ctx, r.First, r.Last); ex != nil { - personID = int(flexID(ex["id"])) - fmt.Printf(" [person] found: [%d] %s %s\n", personID, r.First, r.Last) - } else { - p, err := client.CreatePerson(ctx, r.First, r.Last, companyID, r.JobTitle, "") - if err != nil { - fmt.Printf(" [person] ERR: %v\n", err) - } else { - personID = int(flexID(p["id"])) - fmt.Printf(" [person] created: [%d] %s %s\n", personID, r.First, r.Last) - if r.Email != "" { - _, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Email", r.Email, "Work", true) - } - if r.Phone != "" { - _, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Phone", r.Phone, "Work", false) - } - if r.LinkedIn != "" { - _, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "LinkedIn", r.LinkedIn, "Work", false) - } - } - } - } else { - fmt.Printf(" [person] %s: %s %s\n", action, r.First, r.Last) - } - st.Persons++ - } - var dealID int - if dryRun { - fmt.Printf(" [deal] %s: %s\n", action, dealTitle) - } else { - if ex := findExistingOpp(ctx, client, dealTitle); ex != nil { - dealID = int(flexID(ex["id"])) - fmt.Printf(" [deal] found: [%d] %s\n", dealID, dealTitle) - } else { - d, err := client.CreateOpportunity(ctx, dealTitle, stageInitial, "", "EUR", "", app.SalaryValue) - if err != nil { - fmt.Printf(" [deal] ERR: %v\n", err) - } else { - dealID = int(flexID(d["id"])) - fmt.Printf(" [deal] created: [%d] %s\n", dealID, dealTitle) - } - } - } - st.Deals++ - if !dryRun && dealID != 0 { - if companyID != 0 { - _, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID)) - } - if personID != 0 { - _, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID)) - } - } - if app.Summary != "" { - if dryRun { - fmt.Printf(" [note] %s: application summary\n", action) - } else if dealID != 0 { - if _, err := client.AddHistoryNote(ctx, "opportunity", dealID, app.Summary, 0); err == nil { - fmt.Println(" [note] added application summary") - st.Notes++ - } - } - } - titles := []string{"Write application", "Apply application", "Send CV"} - if dryRun { - for _, t := range titles { - fmt.Printf(" [task] %s: %s\n", action, t) - } - } else if dealID != 0 { - for _, t := range titles { - if _, err := client.CreateCRMTask(ctx, t, deadline, 2, 0, "opportunity", dealID, ""); err == nil { - fmt.Printf(" [task] created: %s\n", t) - st.Tasks++ - } - } - } - for _, doc := range app.Documents { - if dryRun { - fmt.Printf(" [doc] %s: %s\n", action, filepath.Base(doc)) - } else if dealID != 0 { - if _, err := client.UploadOpportunityFile(ctx, strconv.Itoa(dealID), doc); err == nil { - fmt.Printf(" [doc] uploaded: %s\n", filepath.Base(doc)) - st.Docs++ - } - } - } - } - mode := "DRY RUN" - if !dryRun { - mode = "APPLIED" - } - fmt.Println(strings.Repeat("═", 60)) - fmt.Printf(" %s: deals=%d companies=%d persons=%d notes=%d tasks=%d docs=%d\n", - mode, st.Deals, st.Companies, st.Persons, st.Notes, st.Tasks, st.Docs) - return st -} - -func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} { - deals, total, _ := c.ListOpportunities(ctx, 100, 0) - for _, d := range deals { - if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { - return d - } - } - if total > 100 { - deals2, _, _ := c.ListOpportunities(ctx, 100, 100) - for _, d := range deals2 { - if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) { - return d - } - } - } - return nil -} - -func titleWord(s string) string { - if s == "" { - return "" - } - return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) -} - -func flexID(v interface{}) float64 { - switch x := v.(type) { - case float64: - return x - case int: - return float64(x) - case json.Number: - f, _ := x.Float64() - return f - default: - f, _ := strconv.ParseFloat(fmt.Sprint(x), 64) - return f - } -} diff --git a/internal/applications/parse_test.go b/internal/applications/parse_test.go deleted file mode 100644 index 5bfa88e..0000000 --- a/internal/applications/parse_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package applications - -import "testing" - -func TestExtractField(t *testing.T) { - text := "**Position**: Senior Dev\n**Company**: Acme\n" - if g := extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Senior Dev" { - t.Fatalf("position: %q", g) - } - if g := extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Acme" { - t.Fatalf("company: %q", g) - } -} - -func TestParseSalary(t *testing.T) { - if v := parseSalary("€50,000"); v < 40000 { - t.Fatalf("annual: %v", v) - } - if v := parseSalary("400 / day"); v != 400*220 { - t.Fatalf("daily: %v", v) - } -} - -// TestBuildSummaryFitAssessment covers the RE2-safe replacement of a previously -// lookahead-based regex that would panic at MustCompile time on Go regexp. -func TestBuildSummaryFitAssessment(t *testing.T) { - app := Data{Position: "SRE", Company: "Acme", Folder: "/tmp"} - withNextSection := "## Fit Assessment\nGreat fit overall.\nStrong background.\n## Next section\nUnrelated." - summary := buildSummary(app, withNextSection) - if !contains(summary, "Fit Assessment:") { - t.Fatalf("missing header in summary:\n%s", summary) - } - if !contains(summary, "Great fit overall.") { - t.Fatalf("missing body in summary:\n%s", summary) - } - if contains(summary, "Unrelated.") { - t.Fatalf("bled into next section:\n%s", summary) - } - eof := "## Fit Assessment\nOnly content at EOF." - summary = buildSummary(app, eof) - if !contains(summary, "Only content at EOF.") { - t.Fatalf("EOF case missing body:\n%s", summary) - } -} - -func contains(haystack, needle string) bool { - for i := 0; i+len(needle) <= len(haystack); i++ { - if haystack[i:i+len(needle)] == needle { - return true - } - } - return false -} diff --git a/internal/cli/root.go b/internal/cli/root.go deleted file mode 100644 index 3dc3089..0000000 --- a/internal/cli/root.go +++ /dev/null @@ -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 -} diff --git a/onlyoffice.go b/onlyoffice.go deleted file mode 100644 index d7e2580..0000000 --- a/onlyoffice.go +++ /dev/null @@ -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}) -} diff --git a/projects.go b/projects.go new file mode 100644 index 0000000..1b822f3 --- /dev/null +++ b/projects.go @@ -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}) +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..a4bb443 --- /dev/null +++ b/request.go @@ -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 +} diff --git a/tasks.go b/tasks.go new file mode 100644 index 0000000..113c596 --- /dev/null +++ b/tasks.go @@ -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:" 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))) +} diff --git a/tasks_extra.go b/tasks_extra.go deleted file mode 100644 index f124231..0000000 --- a/tasks_extra.go +++ /dev/null @@ -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 -} diff --git a/users.go b/users.go new file mode 100644 index 0000000..0d1af46 --- /dev/null +++ b/users.go @@ -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 +}