Compare commits

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-24 11:30:11 +01:00
eSlider 0e70467522 Add badges: pkg.go.dev, license, Go version, release, stars 2026-02-13 14:02:49 +01:00
25 changed files with 3085 additions and 172 deletions
+38
View File
@@ -0,0 +1,38 @@
---
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
globs:
- "**/*_test.go"
alwaysApply: false
---
# Testing policy — no synthetic vendor mockups
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
other third-party API. Simulated vendor responses drift from reality, give
false green signals, and hide protocol changes.
## What to do instead
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
struct conversions, pure helpers. No `httptest` that fakes the vendor.
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
file. Read credentials from env:
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
regular `go test ./...` stays green in CI.
3. **Run integration**: `go test -tags=integration ./...`.
4. **Every new endpoint** ships with an integration test in the same PR.
## Narrow exception
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
OK when the test server is pretending to be OnlyOffice or Gitea.
## Migrating existing tests
If you find a test that handles routes like `/api/2.0/...` and returns canned
JSON, convert it to an integration test (or delete it if the behaviour is
already covered by integration).
+16
View File
@@ -0,0 +1,16 @@
# OnlyOffice Workspace credentials. Used by cmd/oo-cli (the library itself
# does not load .env files — only its CLI does, via godotenv).
#
# Primary names:
ONLYOFFICE_URL=https://office.example.com
ONLYOFFICE_USER=
ONLYOFFICE_PASS=
# Accepted aliases (only one of each pair is needed):
# ONLYOFFICE_HOST=
# ONLYOFFICE_NAME=
# ONLYOFFICE_PASSWORD=
# Optional defaults for calendar/project helpers:
ONLYOFFICE_CALENDAR_ID=1
ONLYOFFICE_PROJECT_ID=33
+10
View File
@@ -0,0 +1,10 @@
# Local environment
.env
# Go workspace (for local multi-module dev; see inventar ASR-0008)
go.work
go.work.sum
# Build artifacts (compiled top-level binary only; do NOT match cmd/oo-cli source tree)
/oo-cli
/bin/
+41
View File
@@ -0,0 +1,41 @@
# AGENTS — go-onlyoffice
Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the `oo-cli` 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.
## Rules
- Library must never call `godotenv.Load()` — the CLI does that.
- New endpoints go into the library first; CLI commands are thin wrappers.
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
### Testing policy (2026-04-24)
**No synthetic OnlyOffice mockups.** Protocol-level behaviour must be
verified against a real OnlyOffice instance. `httptest.NewServer` is only
acceptable for testing the *caller's* logic that the library can't reach
(for example, the user's own HTTP handler). Anywhere we would otherwise
write `mux.HandleFunc("/api/2.0/...")` to emulate OnlyOffice, we write an
**integration test** instead.
- Unit tests (`*_test.go`, no build tag) — pure Go: parsers, encoders,
struct conversions. No network. No fake servers that emulate the vendor.
- Integration tests (`//go:build integration` tag in `*_integration_test.go`)
— hit a live OnlyOffice instance. Credentials come from `ONLYOFFICE_URL`,
`ONLYOFFICE_USER`, `ONLYOFFICE_PASS` (aliases `_HOST`/`_NAME`/`_PASSWORD`
also accepted). Tests **skip** cleanly when credentials are missing so
`go test ./...` remains green in CI.
- Run integration with: `go test -tags=integration ./...`.
- New endpoints **must** ship with an integration test before merge.
## Related
- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions).
- [`eSlider/inventar-sync`](https://git.produktor.io/eSlider/inventar-sync) — OnlyOffice → Gitea issue sync, consumes this library.
- [`produktor.io/vidarr`](https://git.produktor.io/produktor.io/vidarr) — legacy consumer being migrated from `pkg/onlyoffice` to this module.
+95
View File
@@ -0,0 +1,95 @@
# Changelog
All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.2] - 2026-04-24
### Fixed
- `Project.String()` no longer interprets the title as a format string
(`fmt.Sprintf(*p.Title)`) and is now nil-safe on a zero-value `Project`.
- `internal/applications.buildSummary` no longer panics at regex compile time
on Go 1.23+ — the previous `(?= ...)` lookahead is replaced with an RE2-safe
non-capturing trailing delimiter.
### Changed
- `Client.Query` now routes token acquisition through the shared
`ensureToken` path instead of duplicating the auth-expiry check inline.
- Request body marshalling is consolidated into an unexported
`requestBodyReader` helper (DRY; no change to the public surface).
- The `Request.Debug` field is preserved for backwards compatibility but no
longer changes behaviour — both branches used to unmarshal into the same
target value. We'll remove the field in a future major release.
### Tests
- Deleted `httptest.NewServer` fixtures that emulated OnlyOffice protocol
endpoints. Replaced them with:
- pure-Go unit tests in `unit_test.go` (no network);
- real integration tests in `client_test.go` guarded by
`//go:build integration`. Run with
`go test -tags=integration ./...`. Tests skip cleanly when
`ONLYOFFICE_URL/USER/PASS` (or aliases) are absent.
- New policy documented in `AGENTS.md` and
`.cursor/rules/no-synthetic-mocks.mdc`.
## [0.3.1] - 2026-04-24
### Added
- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and
deadlines. Preferred entry point for long-running syncs (cron, watchers).
- `InvalidateToken()` — clears the cached token to force re-auth on the next
request. Use this to recover from a mid-sync 401 when the server has revoked
the session while the local `Expires` timestamp still looks fresh.
### Notes
- Plain `Authenticate()` is unchanged and remains a convenience wrapper around
`AuthenticateContext(context.Background())`.
- No breaking changes; a patch release.
## [0.3.0] - 2026-04-24
### Added
- Calendar helpers: `ListCalendars`, `ListEvents`, `AddEvent`, `DeleteEvent`.
- CRM helpers: contacts (`ListContacts`, `GetContact`, `FindCompany`, `FindPerson`,
`CreateCompany`, `CreatePerson`, `AddContactInfo`, `DeleteContact`), deals
(`ListOpportunities`, `GetOpportunity`, `CreateOpportunity`,
`AddOpportunityMember`, `ListDealStages`, `DeleteOpportunity`), cases
(`ListCases`, `CreateCase`, `AddCaseMember`, `DeleteCase`), CRM tasks
(`ListCRMTasks`, `CreateCRMTask`, `DeleteCRMTask`, `ListTaskCategories`), and
history notes (`AddHistoryNote`).
- Project task extras: `GetProjectByID`, `ListTasks`, `ListAllTasks`,
`GetTaskByID`, `AddTask`, `AddSubtask`, `UpdateTaskStatus`, `DeleteTask`.
- File upload: `UploadOpportunityFile` (multipart).
- `SelfUserID` cached lookup of `people/@self`.
- `Defaults` struct + `SetDefaults` + `GetEnvironmentDefaults` for optional
calendar/project fallbacks.
- Alias env vars accepted by `GetEnvironmentCredentials`:
`ONLYOFFICE_HOST` / `ONLYOFFICE_NAME` / `ONLYOFFICE_PASSWORD`.
- Public `Authenticate()` that primes the token eagerly.
- Bundled CLI: `cmd/oo-cli` (Cobra) with commands `cal-list`, `cal-events`,
`cal-add`, `cal-delete`, `task-list`, `task-add`, `subtask-add`,
`task-update`, `crm-contacts`, `crm-add-contact`, `crm-deals`,
`crm-add-deal`, `crm-cases`, `applications-sync`.
- httptest-based unit tests for form / multipart / CRM helpers.
### Changed
- The `onlyoffice.Client` struct gained unexported fields (`defaults`, `selfID`,
`noteCatID`); the public API is unchanged and remains backwards compatible.
## [0.2.0] - earlier
- Badges, docs expansion (Gitea sync use case, Gantt/PM workflows).
## [0.1.0] - earlier
- Initial release: OnlyOffice Project Management API client.
+57 -3
View File
@@ -1,5 +1,11 @@
# go-onlyoffice # go-onlyoffice
[![Go Reference](https://pkg.go.dev/badge/github.com/eslider/go-onlyoffice.svg)](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8.svg)](https://go.dev)
[![Latest Release](https://img.shields.io/github/v/tag/eSlider/go-onlyoffice?sort=semver&label=release)](https://github.com/eSlider/go-onlyoffice/releases)
[![GitHub Stars](https://img.shields.io/github/stars/eSlider/go-onlyoffice?style=social)](https://github.com/eSlider/go-onlyoffice/stargazers)
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically. Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically.
Pairs with [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) to bridge developer issue trackers with CRM-grade project management for Gantt charts, resource planning, and executive reporting. Pairs with [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) to bridge developer issue trackers with CRM-grade project management for Gantt charts, resource planning, and executive reporting.
@@ -504,19 +510,67 @@ type Task struct {
| `Time` | `time.Time` wrapper with OnlyOffice JSON format | | `Time` | `time.Time` wrapper with OnlyOffice JSON format |
| `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description | | `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description |
### Calendar, CRM, Subtasks, File upload (v0.2+)
Since v0.2 the library also exposes OnlyOffice Workspace surfaces beyond
Projects: Calendar events, CRM (Contacts, Companies, Opportunities, Cases,
Tasks, History notes) and opportunity file uploads. These helpers return
untyped `map[string]any` for flexibility; callers that need typed structs
should use the typed Project/Task API above.
```go
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
client.SetDefaults(onlyoffice.GetEnvironmentDefaults()) // optional
ctx := context.Background()
// Calendar
events, _ := client.ListEvents(ctx, "2025-01-01", "2025-12-31")
client.AddEvent(ctx, "", "Interview", "2025-06-10T10:00:00Z", "2025-06-10T11:00:00Z", "", false)
// CRM
deals, total, _ := client.ListOpportunities(ctx, 50, 0)
company, _ := client.FindCompany(ctx, "ACME")
// Subtasks (form-encoded)
client.AddSubtask(ctx, "4242", "Prepare CV")
```
### oo-cli (bundled command)
A ready-to-use Cobra CLI wrapping the library lives under `cmd/oo-cli`:
```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
```
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.
## Environment Variables ## Environment Variables
| Variable | Description | | Variable | Description |
|---|---| |---|---|
| `ONLYOFFICE_URL` | OnlyOffice instance URL | | `ONLYOFFICE_URL` (or `ONLYOFFICE_HOST`) | OnlyOffice instance URL |
| `ONLYOFFICE_USER` | Login email or username | | `ONLYOFFICE_USER` (or `ONLYOFFICE_NAME`) | Login email or username |
| `ONLYOFFICE_PASS` | Password | | `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
## Examples ## Examples
| Example | Description | | Example | Description |
|---|---| |---|---|
| [basic](examples/basic/) | List projects and users | | [basic](examples/basic/) | List projects and users |
| [calendar](examples/calendar/) | List calendars and events, create a new event |
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
| [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 |
## Related Libraries ## Related Libraries
+78
View File
@@ -0,0 +1,78 @@
package onlyoffice
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
// ListCalendars returns calendars/events for the given date span (YYYY-MM-DD).
// Empty start/end fall back to a wide default window (2020-01-01..2030-12-31),
// matching the Python reference implementation.
func (c *Client) ListCalendars(ctx context.Context, start, end string) ([]map[string]any, error) {
if start == "" {
start = "2020-01-01"
}
if end == "" {
end = "2030-12-31"
}
path := fmt.Sprintf("/api/2.0/calendar/calendars/%s/%s.json", url.PathEscape(start), url.PathEscape(end))
return c.ResponseArray(ctx, path)
}
// ListEvents is an alias for ListCalendars; retained for the Python-port CLI.
func (c *Client) ListEvents(ctx context.Context, start, end string) ([]map[string]any, error) {
return c.ListCalendars(ctx, start, end)
}
// AddEvent creates a simple one-shot calendar event. When calendarID is empty
// the configured default (SetDefaults or GetEnvironmentDefaults) is used.
// Start/end timestamps are forwarded verbatim (OnlyOffice accepts ISO 8601).
func (c *Client) AddEvent(ctx context.Context, calendarID, title, start, end, description string, allDay bool) (map[string]any, error) {
if calendarID == "" {
calendarID = c.defaults.CalendarID
}
if calendarID == "" {
return nil, fmt.Errorf("AddEvent: calendarID is required (pass explicitly or set via SetDefaults)")
}
fields := url.Values{}
fields.Set("name", title)
fields.Set("description", description)
fields.Set("startDate", start)
fields.Set("endDate", end)
fields.Set("repeatType", "")
fields.Set("alertType", "0")
fields.Set("isAllDayLong", fmt.Sprintf("%t", allDay))
raw, err := c.postForm(ctx, fmt.Sprintf("/api/2.0/calendar/%s/event.json", url.PathEscape(calendarID)), fields)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
var arr []map[string]any
if err := json.Unmarshal(resp, &arr); err != nil {
return nil, err
}
if len(arr) == 0 {
return nil, fmt.Errorf("AddEvent: empty response")
}
return arr[0], nil
}
// DeleteEvent removes a single calendar event by its event ID.
func (c *Client) DeleteEvent(ctx context.Context, eventID string) (map[string]any, error) {
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
}
+192 -100
View File
@@ -1,128 +1,220 @@
//go:build integration
package onlyoffice package onlyoffice
// Integration tests — hit a live OnlyOffice Workspace instance. Credentials
// come from ONLYOFFICE_URL / ONLYOFFICE_USER / ONLYOFFICE_PASS (aliases
// _HOST / _NAME / _PASSWORD also accepted). Without credentials every test
// here skips.
//
// Run with:
//
// go test -tags=integration ./...
//
// These tests are destructive on the target instance. They create projects
// with titles prefixed "go-onlyoffice-test-" and clean up afterwards. Do not
// run against an instance you don't own.
import ( import (
"os" "context"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/joho/godotenv"
) )
// Load environment variables func skipWithoutCredentials(t *testing.T) Credentials {
func init() {
// Load from .env file in the project root
_ = godotenv.Load(".env")
}
func skipWithoutCredentials(t *testing.T) {
t.Helper() t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" { c := GetEnvironmentCredentials()
t.Skip("ONLYOFFICE_URL is not set, skipping integration test") if c.Url == "" || c.User == "" || c.Password == "" {
t.Skip("ONLYOFFICE_URL/USER/PASS not set — skipping integration test")
} }
return c
} }
func TestNewClient(t *testing.T) { func liveClient(t *testing.T) *Client {
skipWithoutCredentials(t) t.Helper()
c := NewClient(skipWithoutCredentials(t))
c.SetDefaults(GetEnvironmentDefaults())
return c
}
// Create a new OnlyOffice client const testProjectPrefix = "go-onlyoffice-test-"
credentials := GetEnvironmentCredentials()
client := NewClient(credentials)
var token *Token
var err error
token, err = client.Auth(&credentials)
func cleanupTestProjects(t *testing.T, c *Client) {
t.Helper()
projects, err := c.GetProjects()
if err != nil { if err != nil {
t.Errorf("Failed to get token: %v", err) t.Logf("cleanup: GetProjects: %v", err)
return
} }
if token == nil { for _, p := range projects {
t.Error("Value is empty") if p.Title != nil && strings.HasPrefix(*p.Title, testProjectPrefix) {
} if _, err := c.DeleteProject(*p.ID); err != nil {
t.Logf("cleanup: DeleteProject %d: %v", *p.ID, err)
// Clean up: remove all projects start with "Test project"
projects, err := client.GetProjects()
if err != nil {
t.Errorf("Failed to get projects: %v", err)
}
for _, project := range projects {
if strings.HasPrefix(*project.Title, "Test project") {
prjStatus, err := client.DeleteProject(*project.ID)
if err != nil {
t.Errorf("Failed to delete project: %v", err)
}
if *prjStatus.ID != *project.ID {
t.Errorf("Project ID is not equal: %v != %v", prjStatus.ID, project.ID)
} }
} }
} }
}
// Test create project func TestIntegrationAuthenticateContext(t *testing.T) {
project, err := client.CreateProject(NewProjectRequest{ c := liveClient(t)
Title: "Test project", ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
Description: "Test project description", defer cancel()
}) if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext: %v", err)
if err != nil {
t.Errorf("Failed to create project: %v", err)
} }
if c.token == nil || c.token.Value == "" {
// Update project t.Fatal("token not cached after AuthenticateContext")
project, err = client.UpdateProject(ProjectUpdateRequest{
ID: *project.ID,
Title: "Test project updated",
ResponsibleID: *project.Responsible.ID,
})
var task *Task
// Test create project task
task, err = client.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID,
Title: "Test task",
Description: "Test task description",
Notify: true,
MilestoneId: 0,
Priority: 0,
// Deadline +2 days
StartDate: Time(time.Now().AddDate(0, 0, -2)),
Deadline: Time(time.Now().AddDate(0, 0, 2)),
})
// Update project task
startDate := Time(time.Now().AddDate(0, 0, -14))
deadline := Time(time.Now().AddDate(0, 0, 3))
task, err = client.UpdateProjectTask(ProjectTaskUpdateRequest{
ID: *task.ID,
Title: "Test task updated",
Description: "Test task description updated",
StartDate: &startDate,
Deadline: &deadline,
})
if err != nil {
t.Errorf("Failed to create task: %v", err)
} }
// Second call must hit the cache.
if task == nil { cachedValue := c.token.Value
t.Error("Value is empty") if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext (cached): %v", err)
} }
if c.token.Value != cachedValue {
// Delete project t.Fatal("cached token was replaced unexpectedly")
prj, err := client.DeleteProject(*project.ID)
if err != nil {
t.Errorf("Failed to delete project: %v", err)
} }
// Invalidate forces re-auth.
if *prj.ID != *project.ID { c.InvalidateToken()
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID) if c.token != nil {
t.Fatal("InvalidateToken did not clear the cache")
} }
if err := c.AuthenticateContext(ctx); err != nil {
// Test list projects t.Fatalf("AuthenticateContext (after invalidate): %v", err)
projects, err = client.GetProjects()
if err != nil {
t.Errorf("Failed to get projects: %v", err)
} }
if len(projects) == 0 { if c.token == nil {
t.Error("Value is empty") t.Fatal("post-invalidate auth left token nil")
}
}
func TestIntegrationGetProjectsAndSelf(t *testing.T) {
c := liveClient(t)
projects, err := c.GetProjects()
if err != nil {
t.Fatalf("GetProjects: %v", err)
}
if projects == nil {
t.Fatal("nil slice from GetProjects")
}
ctx := context.Background()
uid, err := c.SelfUserID(ctx)
if err != nil {
t.Fatalf("SelfUserID: %v", err)
}
if uid == "" {
t.Fatal("empty self user id")
}
}
func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
c := liveClient(t)
t.Cleanup(func() { cleanupTestProjects(t, c) })
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "integration test from go-onlyoffice",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
}
updated, err := c.UpdateProject(ProjectUpdateRequest{
ID: *project.ID,
Title: title + " (updated)",
Description: "updated",
ResponsibleID: *project.Responsible.ID,
})
if err != nil {
t.Fatalf("UpdateProject: %v", err)
}
if updated.Title == nil || *updated.Title != title+" (updated)" {
t.Errorf("title not updated: %+v", updated.Title)
}
start := Time(time.Now().AddDate(0, 0, -2))
deadline := Time(time.Now().AddDate(0, 0, 2))
task, err := c.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID,
Title: "integration parent task",
Description: "from go-onlyoffice integration suite",
StartDate: start,
Deadline: deadline,
Priority: int(TaskPriorityNormal),
})
if err != nil {
t.Fatalf("CreateProjectTask: %v", err)
}
if task.ID == nil {
t.Fatal("created task without id")
}
newStart := Time(time.Now().AddDate(0, 0, -14))
newDeadline := Time(time.Now().AddDate(0, 0, 3))
if _, err := c.UpdateProjectTask(ProjectTaskUpdateRequest{
ID: *task.ID,
Title: "integration parent task (updated)",
Description: "updated",
StartDate: &newStart,
Deadline: &newDeadline,
}); err != nil {
t.Fatalf("UpdateProjectTask: %v", err)
}
// Subtask uses the form-encoded helper; exercises httpx.postForm path.
ctx := context.Background()
sub, err := c.AddSubtask(ctx, strconv.Itoa(*task.ID), "integration subtask")
if err != nil {
t.Fatalf("AddSubtask: %v", err)
}
if _, ok := sub["id"]; !ok {
t.Errorf("subtask response missing id: %+v", sub)
}
}
func TestIntegrationCalendarAndCRMRead(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
if _, err := c.ListCalendars(ctx, start, end); err != nil {
t.Errorf("ListCalendars: %v", err)
}
if _, err := c.ListEvents(ctx, start, end); err != nil {
t.Errorf("ListEvents: %v", err)
}
if _, _, err := c.ListContacts(ctx, 5, 0, ""); err != nil {
t.Errorf("ListContacts: %v", err)
}
if _, _, err := c.ListOpportunities(ctx, 5, 0); err != nil {
t.Errorf("ListOpportunities: %v", err)
}
if _, err := c.ListDealStages(ctx); err != nil {
t.Errorf("ListDealStages: %v", err)
}
}
// TestIntegrationDryRunList covers the tiniest project-tasks read that
// inventar-sync depends on; confirms GetTasks deserialises real responses.
func TestIntegrationListTasks(t *testing.T) {
c := liveClient(t)
defaults := GetEnvironmentDefaults()
// ProjectID default is "33"; skip the read-only list if the caller hasn't
// pointed us at a valid project (we don't know which projects exist).
if defaults.ProjectID == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not configured")
}
pid, err := strconv.Atoi(defaults.ProjectID)
if err != nil || pid <= 0 {
t.Skipf("ONLYOFFICE_PROJECT_ID %q is not a positive int", defaults.ProjectID)
}
if _, err := c.GetTasks(NewProjectGetTasksRequest(pid)); err != nil {
t.Fatalf("GetTasks(%d): %v", pid, err)
} }
} }
+47
View File
@@ -0,0 +1,47 @@
# oo-cli
Cobra CLI wrapper around [`github.com/eslider/go-onlyoffice`](../..). Replaces the Python scripts that used to live under `eSlider/cv/bin/office`.
## Install
```bash
go install github.com/eslider/go-onlyoffice/cmd/oo-cli@latest
```
Ensure `$(go env GOPATH)/bin` is on `PATH`.
## Config
Copy [`../../.env.example`](../../.env.example) to `.env` in the working directory:
| Variable | Alias | Default |
|---|---|---|
| `ONLYOFFICE_URL` | `ONLYOFFICE_HOST` | — |
| `ONLYOFFICE_USER` | `ONLYOFFICE_NAME` | — |
| `ONLYOFFICE_PASS` | `ONLYOFFICE_PASSWORD` | — |
| `ONLYOFFICE_CALENDAR_ID` | — | `1` |
| `ONLYOFFICE_PROJECT_ID` | `ONLYOFFICE_CALENDAR_PROJECT_ID` | `33` |
## Commands
| Python (legacy) | Go CLI |
|---|---|
| `list-calendars.py` | `oo-cli cal-list` |
| `list-events.py` | `oo-cli cal-events [--start] [--end]` |
| `add-event.py` | `oo-cli cal-add TITLE START END [--calendar] [--description] [--all-day]` |
| `delete-event.py` | `oo-cli cal-delete ID [ID...]` |
| `list-tasks.py` | `oo-cli task-list [--project] [--status] [--all] [--verbose]` |
| `add-task.py` | `oo-cli task-add TITLE [--project] [--description] [--deadline] [--priority]` |
| `add-subtask.py` | `oo-cli subtask-add PARENT TITLE [TITLE...]` |
| `update-task.py` | `oo-cli task-update ID open\|closed` or `--delete` |
| `list-contacts.py` | `oo-cli crm-contacts [--companies\|--persons] [--search]` |
| `add-contact.py` | `oo-cli crm-add-contact --company ...` or `--person-first` / `--person-last` |
| `list-deals.py` | `oo-cli crm-deals` / `oo-cli crm-deals --stages` |
| `add-deal.py` | `oo-cli crm-add-deal TITLE [--stage] [--bid] [--contact]` |
| `list-cases.py` | `oo-cli crm-cases` |
| `sync-applications.py` | `oo-cli applications-sync --path .../applications/2026 [--apply]` |
## Notes
- The CLI loads `.env` via `godotenv`; the library itself does not.
- `applications-sync` defaults to dry-run; pass `--apply` to write to CRM.
+20
View File
@@ -0,0 +1,20 @@
// 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)
}
}
+501
View File
@@ -0,0 +1,501 @@
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.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
)
// ListContacts returns a page of CRM contacts and the total count.
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
q := url.Values{}
q.Set("count", fmt.Sprintf("%d", count))
q.Set("startIndex", fmt.Sprintf("%d", startIndex))
if search != "" {
q.Set("filterValue", search)
}
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
if err != nil {
return nil, 0, err
}
var env struct {
Response []map[string]any `json:"response"`
Total int `json:"total"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, 0, err
}
total := env.Total
if total == 0 && len(env.Response) > 0 {
total = len(env.Response)
}
return env.Response, total, nil
}
// GetContact returns a single contact by id.
func (c *Client) GetContact(ctx context.Context, contactID string) (map[string]any, error) {
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
}
// FindCompany searches for a company contact with an exact (case-insensitive)
// displayName match. Returns nil when not found.
func (c *Client) FindCompany(ctx context.Context, name string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, name)
if err != nil {
return nil, err
}
needle := strings.ToLower(strings.TrimSpace(name))
for _, co := range items {
if isCompany(co) && strings.ToLower(fmt.Sprint(co["displayName"])) == needle {
return co, nil
}
}
return nil, nil
}
// FindPerson searches for a person by first+last (case-insensitive).
func (c *Client) FindPerson(ctx context.Context, first, last string) (map[string]any, error) {
items, _, err := c.ListContacts(ctx, 50, 0, first+" "+last)
if err != nil {
return nil, err
}
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) {
return p, nil
}
}
return nil, nil
}
func isCompany(m map[string]any) bool {
v, ok := m["isCompany"].(bool)
return ok && v
}
// CreateCompany creates a company contact with the given name.
func (c *Client) CreateCompany(ctx context.Context, name string) (map[string]any, error) {
fields := url.Values{}
fields.Set("companyName", name)
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
}
// CreatePerson creates a person contact; companyID == 0 means unlinked.
func (c *Client) CreatePerson(ctx context.Context, first, last string, companyID int, jobTitle, about string) (map[string]any, error) {
fields := url.Values{}
fields.Set("firstName", first)
fields.Set("lastName", last)
if companyID != 0 {
fields.Set("companyId", strconv.Itoa(companyID))
}
if jobTitle != "" {
fields.Set("jobTitle", jobTitle)
}
if about != "" {
fields.Set("about", about)
}
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
}
// AddContactInfo attaches an email/website/phone/etc. to a contact.
func (c *Client) AddContactInfo(ctx context.Context, contactID, infoType, dataValue, category string, isPrimary bool) (map[string]any, error) {
if category == "" {
category = "Work"
}
fields := url.Values{}
fields.Set("infoType", infoType)
fields.Set("data", dataValue)
fields.Set("category", category)
fields.Set("isPrimary", 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
}
// 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
}
// 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))
raw, err := c.getJSON(ctx, "/api/2.0/crm/opportunity/filter.json?"+q.Encode())
if err != nil {
return nil, 0, err
}
var env struct {
Response []map[string]any `json:"response"`
Total int `json:"total"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, 0, err
}
return env.Response, env.Total, nil
}
// GetOpportunity returns a single opportunity (deal) by id.
func (c *Client) GetOpportunity(ctx context.Context, id string) (map[string]any, error) {
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
}
// CreateOpportunity creates a new deal. An empty responsibleID falls back to
// the authenticated user's id, and bidCurrency defaults to "EUR".
func (c *Client) CreateOpportunity(ctx context.Context, title string, stageID int, responsibleID, bidCurrency, description string, bidValue float64) (map[string]any, error) {
if responsibleID == "" {
s, err := c.SelfUserID(ctx)
if err != nil {
return nil, err
}
responsibleID = s
}
if bidCurrency == "" {
bidCurrency = "EUR"
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("stageId", strconv.Itoa(stageID))
fields.Set("responsibleId", responsibleID)
fields.Set("bidCurrencyAbbr", bidCurrency)
if bidValue != 0 {
fields.Set("bidValue", fmt.Sprintf("%g", bidValue))
}
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
}
// 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
}
// ListDealStages returns the configured opportunity stages.
func (c *Client) ListDealStages(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/crm/opportunity/stage.json")
}
// DeleteOpportunity removes a deal by id.
func (c *Client) DeleteOpportunity(ctx context.Context, id string) (map[string]any, error) {
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
}
// 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))
raw, err := c.getJSON(ctx, "/api/2.0/crm/case/filter.json?"+q.Encode())
if err != nil {
return nil, 0, err
}
var env struct {
Response []map[string]any `json:"response"`
Total int `json:"total"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, 0, err
}
return env.Response, env.Total, nil
}
// CreateCase creates a new CRM case.
func (c *Client) CreateCase(ctx context.Context, title string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
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
}
// 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
}
// 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
}
// 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))
raw, err := c.getJSON(ctx, "/api/2.0/crm/task/filter.json?"+q.Encode())
if err != nil {
return nil, 0, err
}
var env struct {
Response []map[string]any `json:"response"`
Total int `json:"total"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, 0, err
}
return env.Response, env.Total, nil
}
// CreateCRMTask creates a CRM task (reminder) attached to an entity.
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
fields := url.Values{}
fields.Set("title", title)
fields.Set("deadline", deadline)
fields.Set("categoryId", strconv.Itoa(categoryID))
if contactID != 0 {
fields.Set("contactId", strconv.Itoa(contactID))
}
if entityType != "" {
fields.Set("entityType", entityType)
}
if entityID != 0 {
fields.Set("entityId", strconv.Itoa(entityID))
}
if description != "" {
fields.Set("description", description)
}
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
}
// 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
}
// ListTaskCategories returns CRM task categories.
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
}
// AddHistoryNote attaches a history note to a CRM entity. When categoryID is 0,
// the cached "note" category id is looked up from /api/2.0/crm/history/category.json.
func (c *Client) AddHistoryNote(ctx context.Context, entityType string, entityID int, content string, categoryID int) (map[string]any, error) {
if categoryID == 0 {
id, err := c.historyNoteCategoryID(ctx)
if err != nil {
return nil, err
}
categoryID = id
}
fields := url.Values{}
fields.Set("entityType", entityType)
fields.Set("entityId", strconv.Itoa(entityID))
fields.Set("content", content)
fields.Set("categoryId", strconv.Itoa(categoryID))
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
}
func (c *Client) historyNoteCategoryID(ctx context.Context) (int, error) {
if c.noteCatID != 0 {
return c.noteCatID, nil
}
list, err := c.ResponseArray(ctx, "/api/2.0/crm/history/category.json")
if err != nil {
return 0, err
}
for _, row := range list {
if strings.EqualFold(fmt.Sprint(row["title"]), "note") {
c.noteCatID = int(flexInt(row["id"]))
return c.noteCatID, nil
}
}
if len(list) > 0 {
c.noteCatID = int(flexInt(list[0]["id"]))
return c.noteCatID, nil
}
return 0, nil
}
// flexInt coerces OnlyOffice numeric fields that JSON unmarshal may surface as
// float64, int, json.Number, or string into a plain int64.
func flexInt(v any) int64 {
switch x := v.(type) {
case float64:
return int64(x)
case int:
return int64(x)
case int64:
return x
case json.Number:
n, _ := x.Int64()
return n
default:
n, _ := strconv.ParseInt(fmt.Sprint(x), 10, 64)
return n
}
}
+54
View File
@@ -0,0 +1,54 @@
// Package main shows how to drive the internal applications package — the same
// logic that powers `oo-cli applications-sync` — directly from Go code.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// go run ./examples/applications /path/to/cv/applications
//
// Without --apply equivalent, it prints a summary but performs no writes.
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/applications"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: applications <applications-root>")
os.Exit(2)
}
root := os.Args[1]
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
paths, err := applications.Discover(root)
if err != nil {
log.Fatalf("discover: %v", err)
}
fmt.Printf("discovered %d application READMEs under %s\n", len(paths), root)
var apps []applications.Data
for _, p := range paths {
d, err := applications.ParseReadme(p)
if err != nil {
log.Printf("parse %s: %v", p, err)
continue
}
apps = append(apps, d)
}
stats := applications.Sync(context.Background(), client, apps, true /*dryRun*/, true /*verbose*/)
fmt.Printf("\ndry-run stats: %+v\n", stats)
}
+62
View File
@@ -0,0 +1,62 @@
// Package main shows how to list calendars and events, and create a new event
// via the OnlyOffice Calendar API.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// # optional: default calendar for AddEvent
// export ONLYOFFICE_CALENDAR_ID="42"
// go run ./examples/calendar
package main
import (
"context"
"fmt"
"log"
"os"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func main() {
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
client.SetDefaults(onlyoffice.GetEnvironmentDefaults())
ctx := context.Background()
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
calendars, err := client.ListCalendars(ctx, start, end)
if err != nil {
log.Fatalf("list calendars: %v", err)
}
fmt.Printf("Calendars (%d):\n", len(calendars))
for _, cal := range calendars {
fmt.Printf(" - id=%v title=%v\n", cal["objectId"], cal["title"])
}
events, err := client.ListEvents(ctx, start, end)
if err != nil {
log.Fatalf("list events: %v", err)
}
fmt.Printf("\nEvents in [%s..%s]: %d\n", start, end, len(events))
for _, e := range events {
fmt.Printf(" - %v @ %v\n", e["title"], e["start"])
}
// Create demo event — uncomment to exercise.
// evStart := time.Now().Add(time.Hour).Format(time.RFC3339)
// evEnd := time.Now().Add(2 * time.Hour).Format(time.RFC3339)
// created, err := client.AddEvent(ctx, "", "Library demo", evStart, evEnd, "via examples/calendar", false)
// if err != nil {
// log.Fatalf("add event: %v", err)
// }
// fmt.Printf("created event id=%v\n", created["objectId"])
}
+63
View File
@@ -0,0 +1,63 @@
// Package main lists CRM contacts and opportunities, and shows how to create
// and remove a demo company + opportunity + history note in OnlyOffice CRM.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// go run ./examples/crm
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func main() {
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
ctx := context.Background()
contacts, total, err := client.ListContacts(ctx, 10, 0, "")
if err != nil {
log.Fatalf("list contacts: %v", err)
}
fmt.Printf("CRM contacts: %d (total=%d)\n", len(contacts), total)
for _, c := range contacts {
fmt.Printf(" - id=%v name=%v\n", c["id"], c["displayName"])
}
opps, oppTotal, err := client.ListOpportunities(ctx, 10, 0)
if err != nil {
log.Fatalf("list opportunities: %v", err)
}
fmt.Printf("\nOpportunities: %d (total=%d)\n", len(opps), oppTotal)
for _, o := range opps {
fmt.Printf(" - id=%v title=%v\n", o["id"], o["title"])
}
// Full create/attach/delete cycle — uncomment to exercise.
// company, err := client.CreateCompany(ctx, "Demo GmbH")
// if err != nil { log.Fatalf("create company: %v", err) }
// cid := fmt.Sprint(company["id"])
// defer client.DeleteContact(ctx, cid)
//
// stages, err := client.ListDealStages(ctx)
// if err != nil || len(stages) == 0 { log.Fatalf("no deal stages: %v", err) }
// stageID := int(stages[0]["id"].(float64))
//
// opp, err := client.CreateOpportunity(ctx, "Demo opp", stageID, "", "EUR", "from examples/crm", 0)
// if err != nil { log.Fatalf("create opportunity: %v", err) }
// oid := fmt.Sprint(opp["id"])
// _, _ = client.AddOpportunityMember(ctx, oid, cid)
// _, _ = client.AddHistoryNote(ctx, "opportunity", int(opp["id"].(float64)), "Touched via library example", 0)
// _, _ = client.DeleteOpportunity(ctx, oid)
}
+51
View File
@@ -0,0 +1,51 @@
// Package main creates a parent task and attaches two subtasks using the
// project-tasks API (POST /api/2.0/project/task/{id}.json).
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// export ONLYOFFICE_PROJECT_ID="123"
// go run ./examples/subtasks
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func main() {
creds := onlyoffice.GetEnvironmentCredentials()
defaults := onlyoffice.GetEnvironmentDefaults()
if creds.Url == "" || defaults.ProjectID == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL and ONLYOFFICE_PROJECT_ID must be set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
client.SetDefaults(defaults)
ctx := context.Background()
parent, err := client.AddTask(ctx, defaults.ProjectID, "Example parent", "Created by examples/subtasks", 0, "")
if err != nil {
log.Fatalf("add parent task: %v", err)
}
parentID := fmt.Sprint(parent["id"])
fmt.Printf("parent task id=%s\n", parentID)
for _, title := range []string{"first subtask", "second subtask"} {
st, err := client.AddSubtask(ctx, parentID, title)
if err != nil {
log.Fatalf("add subtask %q: %v", title, err)
}
fmt.Printf(" + subtask id=%v title=%v\n", st["id"], st["title"])
}
// Cleanup — uncomment once you verified the subtasks in the OnlyOffice UI.
// if _, err := client.DeleteTask(ctx, parentID); err != nil {
// log.Printf("cleanup: %v", err)
// }
}
+27
View File
@@ -0,0 +1,27 @@
package onlyoffice
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
// UploadOpportunityFile uploads a single file to a CRM opportunity.
// Returns the decoded "response" object from OnlyOffice.
func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) {
path := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID))
raw, err := c.uploadMultipart(ctx, path, "file", filePath)
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
}
+6
View File
@@ -5,4 +5,10 @@ go 1.22.2
require ( require (
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.10.2
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
) )
+10
View File
@@ -1,6 +1,16 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+310
View File
@@ -0,0 +1,310 @@
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).
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"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, "/")
}
// truncate crops s to n runes, appending "..." when truncated. Used in error
// messages to keep OnlyOffice HTML payloads readable.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// responseField extracts a top-level JSON key (for example "response") from a
// raw OnlyOffice envelope. Returns an error when the key is missing.
func responseField(raw json.RawMessage, key string) (json.RawMessage, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
v, ok := m[key]
if !ok {
return nil, fmt.Errorf("response missing %q", key)
}
return v, nil
}
// ResponseArray executes a GET and returns the "response" field as []map.
// Returns (nil, nil) when the field is JSON null.
func (c *Client) ResponseArray(ctx context.Context, path string) ([]map[string]any, error) {
raw, err := c.getJSON(ctx, path)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if string(resp) == "null" {
return nil, nil
}
var list []map[string]any
if err := json.Unmarshal(resp, &list); err != nil {
return nil, err
}
return list, nil
}
// getJSON issues an authenticated GET and returns the raw response body.
func (c *Client) getJSON(ctx context.Context, path string) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("GET %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// postForm issues an authenticated POST with application/x-www-form-urlencoded body.
func (c *Client) postForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
return c.formRequest(ctx, http.MethodPost, path, fields)
}
// putForm issues an authenticated PUT with application/x-www-form-urlencoded body.
func (c *Client) putForm(ctx context.Context, path string, fields url.Values) (json.RawMessage, error) {
return c.formRequest(ctx, http.MethodPut, path, fields)
}
func (c *Client) formRequest(ctx context.Context, method, path string, fields url.Values) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL()+path, strings.NewReader(fields.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s form %s: %d %s", method, path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// deleteReq issues an authenticated DELETE.
func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
// 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()
if err != nil {
return nil, err
}
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile(fieldName, filepath.Base(filePath))
if err != nil {
return nil, err
}
if _, err := io.Copy(part, f); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, nil
}
+430
View File
@@ -0,0 +1,430 @@
// Package applications syncs job application README trees into OnlyOffice CRM
// (ported from cv/bin/office/sync-applications.py).
package applications
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
)
var docExt = map[string]bool{".pdf": true, ".docx": true, ".xlsx": true, ".doc": true, ".xls": true}
type RecruiterInfo struct {
First, Last, JobTitle, Company, Email, Phone, LinkedIn string
}
type Data struct {
Path string
Folder string
Position string
Company string
Location string
Salary string
SalaryValue float64
Contract string
Source string
Link string
Recruiter RecruiterInfo
Summary string
Documents []string
}
func Discover(base string) ([]string, error) {
var out []string
err := filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if d.Name() == "pdfs" {
return fs.SkipDir
}
return nil
}
if filepath.Base(path) != "README.md" {
return nil
}
dir := filepath.Dir(path)
if filepath.Clean(dir) == filepath.Clean(base) {
return nil
}
out = append(out, path)
return nil
})
return out, err
}
func ParseReadme(path string) (Data, error) {
b, err := os.ReadFile(path)
if err != nil {
return Data{}, err
}
text := string(b)
app := Data{Path: path}
app.Folder = relFolder(path)
app.Position = extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Company = extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`)
if app.Company != "" {
app.Company = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(app.Company, "$1")
app.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(app.Company), "")
}
app.Location = extract(text, `(?i)\*\*Location\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Salary = extract(text, `(?i)\*\*(?:Salary|Gehalt|Rate)\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.SalaryValue = parseSalary(app.Salary)
app.Contract = extract(text, `(?i)\*\*Contract\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Link = extract(text, `(?i)\*\*Link\*\*:\s*(.+?)(?:\s{2,}|\n)`)
if m := regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`).FindStringSubmatch(app.Link); len(m) > 2 {
app.Link = m[2]
}
app.Source = extract(text, `(?i)\*\*Source\*\*:\s*(.+?)(?:\s{2,}|\n)`)
app.Recruiter = parseRecruiter(text)
if app.Source == "" {
app.Source = filepath.Base(filepath.Dir(filepath.Dir(path)))
}
if app.Company == "" || strings.EqualFold(app.Company, "undisclosed") || app.Company == "?" || strings.EqualFold(app.Company, "tbd") {
parts := strings.Split(filepath.Base(filepath.Dir(path)), "-")
if len(parts) >= 2 {
app.Company = titleWord(parts[0])
}
}
app.Summary = buildSummary(app, text)
app.Documents = discoverDocs(filepath.Dir(path))
return app, nil
}
func relFolder(readmePath string) string {
// Python: path.parent.relative_to(path.parent.parent.parent.parent) — approximate: last 3 parts
p := filepath.Clean(readmePath)
parts := strings.Split(p, string(filepath.Separator))
if len(parts) >= 3 {
return filepath.Join(parts[len(parts)-3:]...)
}
return filepath.Dir(readmePath)
}
func extract(text, pat string) string {
re := regexp.MustCompile(pat)
m := re.FindStringSubmatch(text)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func parseSalary(raw string) float64 {
if raw == "" {
return 0
}
cleaned := strings.ReplaceAll(strings.ReplaceAll(raw, ",", ""), ".", "")
if m := regexp.MustCompile(`(\d{4,6})`).FindAllStringSubmatch(cleaned, -1); len(m) > 0 {
var maxv int
for _, x := range m {
n, _ := strconv.Atoi(x[1])
if n > maxv {
maxv = n
}
}
return float64(maxv)
}
if m := regexp.MustCompile(`(?i)(\d{2,4})\s*/\s*day`).FindStringSubmatch(raw); len(m) > 1 {
n, _ := strconv.ParseFloat(m[1], 64)
return n * 220
}
return 0
}
func parseRecruiter(text string) RecruiterInfo {
var r RecruiterInfo
raw := extract(text, `(?i)\*\*(?:Recruiter|Consultant)\*\*:\s*(.+?)(?:\s{2,}|\n)`)
if raw == "" {
return r
}
if m := regexp.MustCompile(`<([^>]+@[^>]+)>`).FindStringSubmatch(raw); len(m) > 1 {
r.Email = strings.TrimSpace(m[1])
if i := strings.Index(raw, "<"); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
raw = strings.TrimRight(raw, " —–-")
}
if m := regexp.MustCompile(`(?i)\s+@\s+(.+)$`).FindStringSubmatch(raw); len(m) > 1 {
r.Company = strings.TrimSpace(m[1])
if i := strings.Index(raw, m[0]); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
}
if m := regexp.MustCompile(`,?\s*\[([^\]]+)\]\([^)]+\)`).FindStringSubmatch(raw); len(m) > 1 {
r.Company = regexp.MustCompile(`\s*\(.*$`).ReplaceAllString(strings.TrimSpace(m[1]), "")
if i := strings.Index(raw, m[0]); i >= 0 {
raw = strings.TrimSpace(raw[:i])
}
}
parts := strings.SplitN(raw, ",", 2)
namePart := strings.TrimSpace(parts[0])
if len(parts) > 1 {
r.JobTitle = strings.TrimSpace(parts[1])
}
toks := strings.Fields(namePart)
if len(toks) >= 2 {
r.First = toks[0]
r.Last = strings.Join(toks[1:], " ")
} else if len(toks) == 1 {
r.First = toks[0]
}
if m := regexp.MustCompile(`(?i)\*\*?(?:Telefon|Phone)\*\*?:\s*([\d+\-\s]+)`).FindStringSubmatch(text); len(m) > 1 {
r.Phone = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?i)\*\*LinkedIn\*\*:\s*(https://[^\s]+)`).FindStringSubmatch(text); len(m) > 1 {
r.LinkedIn = strings.TrimSpace(m[1])
}
return r
}
func buildSummary(app Data, text string) string {
var lines []string
lines = append(lines, "Position: "+app.Position)
lines = append(lines, "Company: "+app.Company)
if app.Location != "" {
lines = append(lines, "Location: "+app.Location)
}
if app.Salary != "" {
lines = append(lines, "Salary: "+app.Salary)
}
if app.Contract != "" {
lines = append(lines, "Contract: "+app.Contract)
}
if app.Source != "" {
lines = append(lines, "Source: "+app.Source)
}
if app.Link != "" {
lines = append(lines, "Link: "+app.Link)
}
lines = append(lines, "Folder: "+app.Folder)
if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 {
lines = append(lines, "", "Status:", strings.TrimSpace(m[1]))
}
// RE2 does not support lookahead; consume the trailing delimiter (\n## or EOS)
// with a non-capturing alternation — the captured group 1 stops right before it.
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?:\n## |$)`).FindStringSubmatch(text); len(m) > 1 {
ft := strings.TrimSpace(m[1])
if len(ft) > 500 {
ft = ft[:500] + "..."
}
lines = append(lines, "", "Fit Assessment:", ft)
}
return strings.Join(lines, "\n")
}
func discoverDocs(dir string) []string {
var docs []string
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if docExt[ext] {
docs = append(docs, filepath.Join(dir, e.Name()))
}
}
return docs
}
type Stats struct {
Companies, Persons, Deals, Tasks, Docs, Notes int
}
// Sync mirrors Python sync_to_crm.
func Sync(ctx context.Context, client *onlyoffice.Client, apps []Data, dryRun, verbose bool) Stats {
var st Stats
deadline := time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
const stageInitial = 1
for _, app := range apps {
dealTitle := app.Position
if app.Company != "" {
dealTitle = app.Position + " @ " + app.Company
}
fmt.Println(strings.Repeat("─", 60))
fmt.Println(" ", app.Folder)
fmt.Println(" ", dealTitle)
if verbose {
// minimal verbose
if app.Salary != "" {
fmt.Printf(" Salary: %s (parsed: %.0f)\n", app.Salary, app.SalaryValue)
}
}
action := "would create"
if !dryRun {
action = "creating"
}
companyID := 0
if app.Company != "" {
if !dryRun {
if ex, _ := client.FindCompany(ctx, app.Company); ex != nil {
companyID = int(flexID(ex["id"]))
fmt.Printf(" [company] found: [%d] %s\n", companyID, app.Company)
} else {
co, err := client.CreateCompany(ctx, app.Company)
if err != nil {
fmt.Printf(" [company] ERR: %v\n", err)
} else {
companyID = int(flexID(co["id"]))
fmt.Printf(" [company] created: [%d] %s\n", companyID, app.Company)
if app.Link != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(companyID), "Website", app.Link, "Work", false)
}
}
}
} else {
fmt.Printf(" [company] %s: %s\n", action, app.Company)
}
st.Companies++
}
personID := 0
r := app.Recruiter
if r.First != "" && r.Last != "" {
if !dryRun {
if ex, _ := client.FindPerson(ctx, r.First, r.Last); ex != nil {
personID = int(flexID(ex["id"]))
fmt.Printf(" [person] found: [%d] %s %s\n", personID, r.First, r.Last)
} else {
p, err := client.CreatePerson(ctx, r.First, r.Last, companyID, r.JobTitle, "")
if err != nil {
fmt.Printf(" [person] ERR: %v\n", err)
} else {
personID = int(flexID(p["id"]))
fmt.Printf(" [person] created: [%d] %s %s\n", personID, r.First, r.Last)
if r.Email != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Email", r.Email, "Work", true)
}
if r.Phone != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "Phone", r.Phone, "Work", false)
}
if r.LinkedIn != "" {
_, _ = client.AddContactInfo(ctx, strconv.Itoa(personID), "LinkedIn", r.LinkedIn, "Work", false)
}
}
}
} else {
fmt.Printf(" [person] %s: %s %s\n", action, r.First, r.Last)
}
st.Persons++
}
var dealID int
if dryRun {
fmt.Printf(" [deal] %s: %s\n", action, dealTitle)
} else {
if ex := findExistingOpp(ctx, client, dealTitle); ex != nil {
dealID = int(flexID(ex["id"]))
fmt.Printf(" [deal] found: [%d] %s\n", dealID, dealTitle)
} else {
d, err := client.CreateOpportunity(ctx, dealTitle, stageInitial, "", "EUR", "", app.SalaryValue)
if err != nil {
fmt.Printf(" [deal] ERR: %v\n", err)
} else {
dealID = int(flexID(d["id"]))
fmt.Printf(" [deal] created: [%d] %s\n", dealID, dealTitle)
}
}
}
st.Deals++
if !dryRun && dealID != 0 {
if companyID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(companyID))
}
if personID != 0 {
_, _ = client.AddOpportunityMember(ctx, strconv.Itoa(dealID), strconv.Itoa(personID))
}
}
if app.Summary != "" {
if dryRun {
fmt.Printf(" [note] %s: application summary\n", action)
} else if dealID != 0 {
if _, err := client.AddHistoryNote(ctx, "opportunity", dealID, app.Summary, 0); err == nil {
fmt.Println(" [note] added application summary")
st.Notes++
}
}
}
titles := []string{"Write application", "Apply application", "Send CV"}
if dryRun {
for _, t := range titles {
fmt.Printf(" [task] %s: %s\n", action, t)
}
} else if dealID != 0 {
for _, t := range titles {
if _, err := client.CreateCRMTask(ctx, t, deadline, 2, 0, "opportunity", dealID, ""); err == nil {
fmt.Printf(" [task] created: %s\n", t)
st.Tasks++
}
}
}
for _, doc := range app.Documents {
if dryRun {
fmt.Printf(" [doc] %s: %s\n", action, filepath.Base(doc))
} else if dealID != 0 {
if _, err := client.UploadOpportunityFile(ctx, strconv.Itoa(dealID), doc); err == nil {
fmt.Printf(" [doc] uploaded: %s\n", filepath.Base(doc))
st.Docs++
}
}
}
}
mode := "DRY RUN"
if !dryRun {
mode = "APPLIED"
}
fmt.Println(strings.Repeat("═", 60))
fmt.Printf(" %s: deals=%d companies=%d persons=%d notes=%d tasks=%d docs=%d\n",
mode, st.Deals, st.Companies, st.Persons, st.Notes, st.Tasks, st.Docs)
return st
}
func findExistingOpp(ctx context.Context, c *onlyoffice.Client, title string) map[string]interface{} {
deals, total, _ := c.ListOpportunities(ctx, 100, 0)
for _, d := range deals {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
return d
}
}
if total > 100 {
deals2, _, _ := c.ListOpportunities(ctx, 100, 100)
for _, d := range deals2 {
if strings.TrimSpace(fmt.Sprint(d["title"])) == strings.TrimSpace(title) {
return d
}
}
}
return nil
}
func titleWord(s string) string {
if s == "" {
return ""
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
func flexID(v interface{}) float64 {
switch x := v.(type) {
case float64:
return x
case int:
return float64(x)
case json.Number:
f, _ := x.Float64()
return f
default:
f, _ := strconv.ParseFloat(fmt.Sprint(x), 64)
return f
}
}
+53
View File
@@ -0,0 +1,53 @@
package applications
import "testing"
func TestExtractField(t *testing.T) {
text := "**Position**: Senior Dev\n**Company**: Acme\n"
if g := extract(text, `(?i)\*\*Position\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Senior Dev" {
t.Fatalf("position: %q", g)
}
if g := extract(text, `(?i)\*\*Company\*\*:\s*(.+?)(?:\s{2,}|\n)`); g != "Acme" {
t.Fatalf("company: %q", g)
}
}
func TestParseSalary(t *testing.T) {
if v := parseSalary("€50,000"); v < 40000 {
t.Fatalf("annual: %v", v)
}
if v := parseSalary("400 / day"); v != 400*220 {
t.Fatalf("daily: %v", v)
}
}
// TestBuildSummaryFitAssessment covers the RE2-safe replacement of a previously
// lookahead-based regex that would panic at MustCompile time on Go regexp.
func TestBuildSummaryFitAssessment(t *testing.T) {
app := Data{Position: "SRE", Company: "Acme", Folder: "/tmp"}
withNextSection := "## Fit Assessment\nGreat fit overall.\nStrong background.\n## Next section\nUnrelated."
summary := buildSummary(app, withNextSection)
if !contains(summary, "Fit Assessment:") {
t.Fatalf("missing header in summary:\n%s", summary)
}
if !contains(summary, "Great fit overall.") {
t.Fatalf("missing body in summary:\n%s", summary)
}
if contains(summary, "Unrelated.") {
t.Fatalf("bled into next section:\n%s", summary)
}
eof := "## Fit Assessment\nOnly content at EOF."
summary = buildSummary(app, eof)
if !contains(summary, "Only content at EOF.") {
t.Fatalf("EOF case missing body:\n%s", summary)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+517
View File
@@ -0,0 +1,517 @@
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
}
+105 -69
View File
@@ -3,15 +3,17 @@ package onlyoffice
// OnlyOffice client package // OnlyOffice client package
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/google/go-querystring/query"
"io" "io"
"net/http" "net/http"
"os" "os"
regexp "regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/google/go-querystring/query"
) )
// NewClient API // NewClient API
@@ -22,14 +24,61 @@ func NewClient(c Credentials) *Client {
} }
} }
// GetEnvironmentCredentials when using environment variables // Defaults holds optional fallbacks used by package-level helpers when callers
func GetEnvironmentCredentials() Credentials { // pass an empty identifier (calendar or project). Set via (*Client).SetDefaults.
return Credentials{ type Defaults struct {
Url: os.Getenv("ONLYOFFICE_URL"), CalendarID string
User: os.Getenv("ONLYOFFICE_USER"), ProjectID string
Password: os.Getenv("ONLYOFFICE_PASS"), }
}
// 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 // Credentials of OnlyOffice User
@@ -53,6 +102,10 @@ type Client struct {
client *http.Client // HTTP client client *http.Client // HTTP client
credentials *Credentials // OnlyOffice credentials credentials *Credentials // OnlyOffice credentials
token *Token // Authentication token 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 // MetaResponse Response
@@ -244,7 +297,10 @@ type ProjectOwner struct {
// String for Project to return title // String for Project to return title
func (p Project) String() string { func (p Project) String() string {
return fmt.Sprintf(*p.Title) if p.Title == nil {
return ""
}
return *p.Title
} }
// Token OnlyOffice // Token OnlyOffice
@@ -302,16 +358,16 @@ func (r Request) GetMethod() string {
// Query the OnlyOffice API // Query the OnlyOffice API
// - If request.Method is not set then it will default to GET // - If request.Method is not set then it will default to GET
// - If request.Code is not nil then it will be marshaled to JSON // - If request.Body is not nil then it will be marshaled to JSON (unless already []byte/string)
// - If request.Token is nil then it will get a token // - 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.Token is not nil then it will be used as Authorization header
// - If request.NoAuth is true then it will skip automatic authentication // - If request.NoAuth is true then it will skip automatic authentication
func (c *Client) Query(request Request, result interface{}) (err error) { //
var url = fmt.Sprintf("%s%s", c.credentials.Url, request.Uri) // The Debug field is preserved for backwards compatibility; it no longer
var rdr io.Reader = nil // changes behaviour — both branches used to unmarshal into the same target.
var jsonRequestBody string func (c *Client) Query(request Request, result interface{}) error {
url := c.credentials.Url + request.Uri
// Add query parameters if available
if request.Params != nil { if request.Params != nil {
v, err := query.Values(request.Params) v, err := query.Values(request.Params)
if err != nil { if err != nil {
@@ -320,78 +376,62 @@ func (c *Client) Query(request Request, result interface{}) (err error) {
url = fmt.Sprintf("%s?%s", url, v.Encode()) url = fmt.Sprintf("%s?%s", url, v.Encode())
} }
// Create request rdr, err := requestBodyReader(request.Body)
if request.Body != nil { if err != nil {
// Check request body if type is not []byte or string the marshal it to JSON return err
switch request.Body.(type) {
case []byte:
jsonRequestBody = string(request.Body.([]byte))
rdr = strings.NewReader(string(request.Body.([]byte)))
case string:
jsonRequestBody = request.Body.(string)
rdr = strings.NewReader(request.Body.(string))
default:
// Marshal to JSON
b, err := json.Marshal(request.Body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %v", err)
}
jsonRequestBody = string(b)
rdr = strings.NewReader(string(b))
}
} }
req, err := http.NewRequest(request.GetMethod(), url, rdr) req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache") req.Header.Set("Pragma", "no-cache")
// Get token if not set?
if !request.NoAuth { if !request.NoAuth {
// Get token if not set or expired if err := c.ensureToken(); err != nil {
if c.token == nil || time.Time(c.token.Expires).Before(time.Now()) { return fmt.Errorf("failed to authenticate: %w", err)
c.token, err = c.Auth(c.credentials)
if err != nil {
return fmt.Errorf("failed to authenticate: %v", err)
}
} }
} }
switch {
// Set token if available case request.Token != nil:
if request.Token != nil {
req.Header.Set("Authorization", *request.Token) req.Header.Set("Authorization", *request.Token)
} else { case c.token != nil:
// Set token from a client if available req.Header.Set("Authorization", c.token.Value)
if c.token != nil {
req.Header.Set("Authorization", c.token.Value)
}
} }
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to send request: %v", err) return fmt.Errorf("failed to send request: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if result == nil { if result == nil {
return nil return nil
} }
if request.Debug { return json.NewDecoder(resp.Body).Decode(result)
// Rewind reader }
_ = jsonRequestBody
var buf = new(strings.Builder)
io.Copy(buf, resp.Body)
js := buf.String()
return json.Unmarshal([]byte(js), result)
} else {
// Unmarshal response using reader
return json.NewDecoder(resp.Body).Decode(result)
}
// 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 // Auth to authenticate by getting a token using credentials
@@ -438,7 +478,7 @@ func (c *Client) GetProjects() (list Projects, err error) {
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) { func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID), Debug: false}, err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID)},
&struct { &struct {
MetaResponse `json:",inline"` MetaResponse `json:",inline"`
//Response *[]*Milestone //Response *[]*Milestone
@@ -605,9 +645,7 @@ func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (task *Task, er
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID), Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT", Method: "PUT",
Body: req, Body: req,
Debug: true,
}, &struct { }, &struct {
//MetaResponse `json:",inline"`
Response *Task `json:"response"` Response *Task `json:"response"`
}{task}) }{task})
} }
@@ -641,10 +679,8 @@ func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error)
Request{ Request{
Uri: "/api/2.0/project/task/filter.json", Uri: "/api/2.0/project/task/filter.json",
Params: req, Params: req,
Debug: true,
}, },
&struct { &struct {
//MetaResponse `json:",inline"`
Response *[]*Task `json:"response"` Response *[]*Task `json:"response"`
}{&tasks}) }{&tasks})
} }
+169
View File
@@ -0,0 +1,169 @@
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
}
+133
View File
@@ -0,0 +1,133 @@
package onlyoffice
// Pure unit tests — no network, no fake vendor HTTP servers.
// Protocol-level behaviour is covered by *_integration_test.go (build-tagged).
import (
"context"
"encoding/json"
"testing"
"time"
)
func TestResponseFieldMissing(t *testing.T) {
if _, err := responseField(json.RawMessage(`{"other":1}`), "response"); err == nil {
t.Fatal("expected error on missing field")
}
}
func TestResponseFieldPresent(t *testing.T) {
raw, err := responseField(json.RawMessage(`{"response":[1,2,3],"other":9}`), "response")
if err != nil {
t.Fatal(err)
}
if string(raw) != "[1,2,3]" {
t.Errorf("got %s", string(raw))
}
}
func TestRequestBodyReaderNil(t *testing.T) {
r, err := requestBodyReader(nil)
if err != nil {
t.Fatal(err)
}
if r != nil {
t.Errorf("expected nil reader for nil body, got %T", r)
}
}
func TestRequestBodyReaderBytes(t *testing.T) {
r, err := requestBodyReader([]byte(`{"k":1}`))
if err != nil {
t.Fatal(err)
}
if r == nil {
t.Fatal("nil reader")
}
}
func TestRequestBodyReaderStruct(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
r, err := requestBodyReader(payload{Name: "x"})
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 64)
n, _ := r.Read(buf)
if string(buf[:n]) != `{"name":"x"}` {
t.Errorf("got %q", string(buf[:n]))
}
}
func TestProjectStringNilSafe(t *testing.T) {
var p Project
if got := p.String(); got != "" {
t.Errorf("nil Title should yield empty string, got %q", got)
}
title := "my project"
p.Title = &title
if got := p.String(); got != "my project" {
t.Errorf("got %q", got)
}
titleWithPercent := "100% coverage"
p.Title = &titleWithPercent
if got := p.String(); got != "100% coverage" {
t.Errorf("Sprintf format-string regression: got %q", got)
}
}
func TestAuthenticateContextRespectsCancellation(t *testing.T) {
// Point the client at a routable-but-unresponsive endpoint (TEST-NET-1
// per RFC 5737) and cancel the context almost immediately. The test
// verifies ctx plumbing, not OnlyOffice protocol — no vendor mock.
c := NewClient(Credentials{Url: "http://192.0.2.1:9", User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
if err := c.AuthenticateContext(ctx); err == nil {
t.Fatal("expected error on context timeout against unreachable endpoint")
}
}
func TestInvalidateTokenIsIdempotent(t *testing.T) {
c := NewClient(Credentials{Url: "http://example.invalid", User: "u", Password: "p"})
c.InvalidateToken()
c.InvalidateToken()
if c.token != nil {
t.Fatal("token should remain nil after double invalidate")
}
}
func TestGetEnvironmentCredentialsAliases(t *testing.T) {
t.Setenv("ONLYOFFICE_URL", "")
t.Setenv("ONLYOFFICE_HOST", "https://example/")
t.Setenv("ONLYOFFICE_USER", "")
t.Setenv("ONLYOFFICE_NAME", "alice")
t.Setenv("ONLYOFFICE_PASS", "")
t.Setenv("ONLYOFFICE_PASSWORD", "s3cret")
c := GetEnvironmentCredentials()
if c.Url != "https://example" {
t.Errorf("Url alias not applied / trailing slash not trimmed: %q", c.Url)
}
if c.User != "alice" {
t.Errorf("User alias not applied: %q", c.User)
}
if c.Password != "s3cret" {
t.Errorf("Password alias not applied: %q", c.Password)
}
}
func TestGetEnvironmentDefaultsFallbacks(t *testing.T) {
t.Setenv("ONLYOFFICE_CALENDAR_ID", "")
t.Setenv("ONLYOFFICE_PROJECT_ID", "")
t.Setenv("ONLYOFFICE_CALENDAR_PROJECT_ID", "")
d := GetEnvironmentDefaults()
if d.CalendarID != "1" || d.ProjectID != "33" {
t.Errorf("defaults: %+v", d)
}
t.Setenv("ONLYOFFICE_CALENDAR_PROJECT_ID", "7")
if got := GetEnvironmentDefaults().ProjectID; got != "7" {
t.Errorf("CalendarProjectId alias: %q", got)
}
}