Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fc2adadd1 | ||
|
|
b63055d435 | ||
|
|
842e9aa132 | ||
|
|
0e70467522 | ||
|
|
a4f9fa2ef8 |
@@ -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
@@ -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/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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.
|
||||||
|
- Prefer httptest-based unit tests (see `httpx_test.go`); integration tests (`client_test.go`) auto-skip without `ONLYOFFICE_URL`.
|
||||||
|
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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.1] - 2026-04-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and
|
||||||
|
deadlines. Preferred entry point for long-running syncs (cron, watchers).
|
||||||
|
- `InvalidateToken()` — clears the cached token to force re-auth on the next
|
||||||
|
request. Use this to recover from a mid-sync 401 when the server has revoked
|
||||||
|
the session while the local `Expires` timestamp still looks fresh.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Plain `Authenticate()` is unchanged and remains a convenience wrapper around
|
||||||
|
`AuthenticateContext(context.Background())`.
|
||||||
|
- No breaking changes; a patch release.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-04-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Calendar helpers: `ListCalendars`, `ListEvents`, `AddEvent`, `DeleteEvent`.
|
||||||
|
- CRM helpers: contacts (`ListContacts`, `GetContact`, `FindCompany`, `FindPerson`,
|
||||||
|
`CreateCompany`, `CreatePerson`, `AddContactInfo`, `DeleteContact`), deals
|
||||||
|
(`ListOpportunities`, `GetOpportunity`, `CreateOpportunity`,
|
||||||
|
`AddOpportunityMember`, `ListDealStages`, `DeleteOpportunity`), cases
|
||||||
|
(`ListCases`, `CreateCase`, `AddCaseMember`, `DeleteCase`), CRM tasks
|
||||||
|
(`ListCRMTasks`, `CreateCRMTask`, `DeleteCRMTask`, `ListTaskCategories`), and
|
||||||
|
history notes (`AddHistoryNote`).
|
||||||
|
- Project task extras: `GetProjectByID`, `ListTasks`, `ListAllTasks`,
|
||||||
|
`GetTaskByID`, `AddTask`, `AddSubtask`, `UpdateTaskStatus`, `DeleteTask`.
|
||||||
|
- File upload: `UploadOpportunityFile` (multipart).
|
||||||
|
- `SelfUserID` cached lookup of `people/@self`.
|
||||||
|
- `Defaults` struct + `SetDefaults` + `GetEnvironmentDefaults` for optional
|
||||||
|
calendar/project fallbacks.
|
||||||
|
- Alias env vars accepted by `GetEnvironmentCredentials`:
|
||||||
|
`ONLYOFFICE_HOST` / `ONLYOFFICE_NAME` / `ONLYOFFICE_PASSWORD`.
|
||||||
|
- Public `Authenticate()` that primes the token eagerly.
|
||||||
|
- Bundled CLI: `cmd/oo-cli` (Cobra) with commands `cal-list`, `cal-events`,
|
||||||
|
`cal-add`, `cal-delete`, `task-list`, `task-add`, `subtask-add`,
|
||||||
|
`task-update`, `crm-contacts`, `crm-add-contact`, `crm-deals`,
|
||||||
|
`crm-add-deal`, `crm-cases`, `applications-sync`.
|
||||||
|
- httptest-based unit tests for form / multipart / CRM helpers.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The `onlyoffice.Client` struct gained unexported fields (`defaults`, `selfID`,
|
||||||
|
`noteCatID`); the public API is unchanged and remains backwards compatible.
|
||||||
|
|
||||||
|
## [0.2.0] - earlier
|
||||||
|
|
||||||
|
- Badges, docs expansion (Gitea sync use case, Gantt/PM workflows).
|
||||||
|
|
||||||
|
## [0.1.0] - earlier
|
||||||
|
|
||||||
|
- Initial release: OnlyOffice Project Management API client.
|
||||||
@@ -1,15 +1,107 @@
|
|||||||
# go-onlyoffice
|
# go-onlyoffice
|
||||||
|
|
||||||
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API.
|
[](https://pkg.go.dev/github.com/eslider/go-onlyoffice)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://go.dev)
|
||||||
|
[](https://github.com/eSlider/go-onlyoffice/releases)
|
||||||
|
[](https://github.com/eSlider/go-onlyoffice/stargazers)
|
||||||
|
|
||||||
## Features
|
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API — manage projects, tasks, subtasks, milestones, and users programmatically.
|
||||||
|
|
||||||
- Token-based authentication with automatic renewal
|
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.
|
||||||
- Project CRUD operations (create, read, update, delete)
|
|
||||||
- Task management (create, update, list, filter)
|
## Architecture
|
||||||
- Milestone management
|
|
||||||
- User listing
|
```mermaid
|
||||||
- Query parameter serialization via struct tags
|
graph TB
|
||||||
|
subgraph "Developer Tools"
|
||||||
|
GIT["Gitea / GitHub<br/>Issues, PRs, Milestones"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "go-onlyoffice"
|
||||||
|
CL["Client"]
|
||||||
|
AUTH["Auth<br/>Token-based"]
|
||||||
|
PRJ["Projects"]
|
||||||
|
TSK["Tasks & Subtasks"]
|
||||||
|
MS["Milestones"]
|
||||||
|
USR["Users"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "OnlyOffice CRM"
|
||||||
|
GANTT["Gantt Charts"]
|
||||||
|
PLAN["Project Planning"]
|
||||||
|
RPT["Reports & Dashboards"]
|
||||||
|
PM["PM Workflow"]
|
||||||
|
end
|
||||||
|
|
||||||
|
GIT -->|"sync issues"| CL
|
||||||
|
CL --> AUTH
|
||||||
|
AUTH --> PRJ
|
||||||
|
AUTH --> TSK
|
||||||
|
AUTH --> MS
|
||||||
|
AUTH --> USR
|
||||||
|
|
||||||
|
PRJ --> GANTT
|
||||||
|
TSK --> GANTT
|
||||||
|
MS --> PLAN
|
||||||
|
TSK --> RPT
|
||||||
|
PRJ --> PM
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Problem: Developers vs. Project Managers
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Engineering World"
|
||||||
|
DEV["Developers"]
|
||||||
|
GITEA["Gitea / GitHub<br/>Issues & PRs"]
|
||||||
|
CODE["Code Reviews"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Management World"
|
||||||
|
PM["Project Managers"]
|
||||||
|
OO["OnlyOffice CRM<br/>Gantt · Planning · Reports"]
|
||||||
|
EXEC["Executives<br/>Status Reports"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DEV -->|"create issues"| GITEA
|
||||||
|
GITEA -.->|"❌ invisible"| PM
|
||||||
|
PM -->|"manual copy"| OO
|
||||||
|
OO --> EXEC
|
||||||
|
|
||||||
|
style GITEA fill:#f96,stroke:#333
|
||||||
|
style OO fill:#69f,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
**Without sync:** Project managers manually copy issue titles, deadlines, and status from Gitea into OnlyOffice. Developers don't update the CRM. Gantt charts rot. Reports lie.
|
||||||
|
|
||||||
|
**With sync:** Issues flow automatically from Gitea to OnlyOffice with start dates, deadlines, and status. PMs get live Gantt charts. Developers keep working in Git.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph "Engineering World"
|
||||||
|
DEV["Developers"]
|
||||||
|
GITEA["Gitea / GitHub"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Sync Bridge"
|
||||||
|
SYNC["go-onlyoffice<br/>+ go-gitea-helpers"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Management World"
|
||||||
|
OO["OnlyOffice CRM"]
|
||||||
|
GANTT["Gantt Charts ✓"]
|
||||||
|
RPT["Reports ✓"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DEV -->|"create/close issues"| GITEA
|
||||||
|
GITEA -->|"auto-sync"| SYNC
|
||||||
|
SYNC -->|"create/update tasks"| OO
|
||||||
|
OO --> GANTT
|
||||||
|
OO --> RPT
|
||||||
|
|
||||||
|
style SYNC fill:#4a4,stroke:#333,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -17,7 +109,143 @@ Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Mana
|
|||||||
go get github.com/eslider/go-onlyoffice
|
go get github.com/eslider/go-onlyoffice
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
For Gitea sync (optional):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/eslider/go-gitea-helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Connect and List Projects
|
||||||
|
|
||||||
|
```go
|
||||||
|
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||||
|
|
||||||
|
projects, _ := client.GetProjects()
|
||||||
|
for _, p := range projects {
|
||||||
|
fmt.Printf("[%d] %s — %d tasks\n", *p.ID, *p.Title, safeInt(p.TaskCountTotal))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a Project with Tasks and Deadlines
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create a project
|
||||||
|
project, _ := client.CreateProject(onlyoffice.NewProjectRequest{
|
||||||
|
Title: "Q1 2026 Release",
|
||||||
|
Description: "Backend API v2 + mobile app redesign",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create tasks with start/end dates (for Gantt chart)
|
||||||
|
client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{
|
||||||
|
ProjectId: *project.ID,
|
||||||
|
Title: "Design API schema",
|
||||||
|
Description: "OpenAPI 3.1 spec for all endpoints",
|
||||||
|
StartDate: onlyoffice.Time(time.Now()),
|
||||||
|
Deadline: onlyoffice.Time(time.Now().AddDate(0, 0, 14)),
|
||||||
|
Priority: 1, // High
|
||||||
|
})
|
||||||
|
|
||||||
|
client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{
|
||||||
|
ProjectId: *project.ID,
|
||||||
|
Title: "Implement auth service",
|
||||||
|
Description: "JWT + OAuth2 + refresh tokens",
|
||||||
|
StartDate: onlyoffice.Time(time.Now().AddDate(0, 0, 14)),
|
||||||
|
Deadline: onlyoffice.Time(time.Now().AddDate(0, 1, 0)),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### List and Filter Tasks
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Get all tasks for a project
|
||||||
|
tasks, _ := client.GetTasks(onlyoffice.NewProjectGetTasksRequest(*project.ID))
|
||||||
|
|
||||||
|
for _, t := range tasks {
|
||||||
|
status := "open"
|
||||||
|
if t.Status != nil && *t.Status == onlyoffice.ProjectTaskStatusClosed {
|
||||||
|
status = "closed"
|
||||||
|
}
|
||||||
|
fmt.Printf(" [%s] %s", status, *t.Title)
|
||||||
|
if t.Deadline != nil {
|
||||||
|
fmt.Printf(" (due: %s)", t.Deadline.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Task Status and Dates
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Close a task and set actual end date
|
||||||
|
client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
|
||||||
|
ID: taskID,
|
||||||
|
Title: "Design API schema",
|
||||||
|
Status: onlyoffice.ProjectTaskStatusClosed,
|
||||||
|
Deadline: &onlyoffice.Time(time.Now()),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Milestones and Task Progress
|
||||||
|
|
||||||
|
```go
|
||||||
|
milestones, _ := client.GetProjectMilestones(project)
|
||||||
|
for _, ms := range milestones {
|
||||||
|
active := int64(0)
|
||||||
|
closed := int64(0)
|
||||||
|
if ms.ActiveTaskCount != nil { active = *ms.ActiveTaskCount }
|
||||||
|
if ms.ClosedTaskCount != nil { closed = *ms.ClosedTaskCount }
|
||||||
|
total := active + closed
|
||||||
|
|
||||||
|
fmt.Printf("Milestone: %s — %d/%d tasks done", *ms.Title, closed, total)
|
||||||
|
if ms.Deadline != nil {
|
||||||
|
fmt.Printf(" (deadline: %s)", ms.Deadline.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Case: Gitea → OnlyOffice Sync
|
||||||
|
|
||||||
|
The primary use case is **bridging developer workflows with project management**. Developers create issues in Gitea; a sync job automatically mirrors them as OnlyOffice tasks with proper start/end dates, enabling PMs to work with Gantt charts without developers leaving their Git workflow.
|
||||||
|
|
||||||
|
### Sync Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Dev as Developer
|
||||||
|
participant Gitea
|
||||||
|
participant Sync as Sync Job
|
||||||
|
participant OO as OnlyOffice
|
||||||
|
|
||||||
|
Dev->>Gitea: Create issue "Add OAuth2"
|
||||||
|
Note over Gitea: issue.Created = Feb 13<br/>issue.Deadline = Mar 1
|
||||||
|
|
||||||
|
Sync->>Gitea: GET /repos/{org}/*/issues
|
||||||
|
Gitea-->>Sync: issues list (paginated)
|
||||||
|
|
||||||
|
Sync->>OO: GET /api/2.0/project/filter.json
|
||||||
|
OO-->>Sync: projects list
|
||||||
|
|
||||||
|
Sync->>Sync: Match Gitea labels → OO projects
|
||||||
|
|
||||||
|
alt Issue not yet in OnlyOffice
|
||||||
|
Sync->>OO: POST /api/2.0/project/{id}/task.json
|
||||||
|
Note over OO: Task created:<br/>Start: Feb 13, End: Mar 1<br/>Description includes Gitea URL
|
||||||
|
else Issue already synced
|
||||||
|
Sync->>OO: PUT /api/2.0/project/task/{id}.json
|
||||||
|
Note over OO: Title, status, dates updated
|
||||||
|
end
|
||||||
|
|
||||||
|
Dev->>Gitea: Close issue "Add OAuth2"
|
||||||
|
Sync->>OO: PUT status → Closed
|
||||||
|
Note over OO: Gantt chart updates automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync Example
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@@ -25,68 +253,332 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gitea "github.com/eslider/go-gitea-helpers"
|
||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
client := onlyoffice.NewClient(onlyoffice.Credentials{
|
// Connect to both services
|
||||||
Url: "https://your-onlyoffice.example.com",
|
oo := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||||
User: "admin@example.com",
|
gc, _ := gitea.NewClient(gitea.GetEnvironmentConfig())
|
||||||
Password: "your-password",
|
owner := os.Getenv("GITEA_OWNER")
|
||||||
})
|
|
||||||
|
|
||||||
// List projects
|
// Load all Gitea issues and OnlyOffice projects
|
||||||
projects, err := client.GetProjects()
|
repos, _ := gc.GetAllReposIssues(owner)
|
||||||
if err != nil {
|
projects, _ := oo.GetProjects()
|
||||||
log.Fatal(err)
|
|
||||||
|
for repoName, repo := range repos {
|
||||||
|
// Find matching OnlyOffice project by name
|
||||||
|
project := projects.Get(repoName)
|
||||||
|
if project == nil {
|
||||||
|
fmt.Printf("SKIP %s (no matching OO project)\n", repoName)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
for _, p := range projects {
|
|
||||||
fmt.Printf("Project: %s (ID: %d)\n", *p.Title, *p.ID)
|
// Load existing tasks
|
||||||
|
tasks, _ := oo.GetTasks(onlyoffice.NewProjectGetTasksRequest(*project.ID))
|
||||||
|
|
||||||
|
for _, issue := range repo.Issues {
|
||||||
|
// Check if issue is already synced (URL in description)
|
||||||
|
existing := findSyncedTask(tasks, issue.HTMLURL)
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
// Update existing task
|
||||||
|
status := onlyoffice.ProjectTaskStatusOpen
|
||||||
|
if issue.State == "closed" {
|
||||||
|
status = onlyoffice.ProjectTaskStatusClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oo.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
|
||||||
|
ID: *existing.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Status: status,
|
||||||
|
StartDate: timePtr(onlyoffice.Time(issue.Created)),
|
||||||
|
Deadline: deadlineFromIssue(issue),
|
||||||
|
})
|
||||||
|
fmt.Printf(" UPDATED: %s\n", issue.Title)
|
||||||
|
} else {
|
||||||
|
// Create new task
|
||||||
|
status := onlyoffice.ProjectTaskStatusOpen
|
||||||
|
if issue.State == "closed" {
|
||||||
|
status = onlyoffice.ProjectTaskStatusClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
oo.CreateProjectTask(onlyoffice.NewProjectTaskRequest{
|
||||||
|
ProjectId: *project.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Description: issue.Body + "\n\nURL:" + issue.HTMLURL,
|
||||||
|
StartDate: onlyoffice.Time(issue.Created),
|
||||||
|
Deadline: *deadlineFromIssue(issue),
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
fmt.Printf(" CREATED: %s\n", issue.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSyncedTask checks task descriptions for the Gitea issue URL.
|
||||||
|
func findSyncedTask(tasks []*onlyoffice.Task, issueURL string) *onlyoffice.Task {
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Description != nil && strings.Contains(*t.Description, issueURL) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Or load credentials from environment variables:
|
### What Project Managers Get
|
||||||
|
|
||||||
|
Once synced, OnlyOffice provides without any developer intervention:
|
||||||
|
|
||||||
|
| Feature | How It Works |
|
||||||
|
|---|---|
|
||||||
|
| **Gantt Charts** | Tasks have `StartDate` and `Deadline` from Gitea issue created/due dates |
|
||||||
|
| **Status Tracking** | Open/closed status mirrors Gitea issue state in real time |
|
||||||
|
| **Milestone Planning** | Gitea milestones map to OnlyOffice milestones with progress % |
|
||||||
|
| **Resource Allocation** | Task assignees sync so PMs see who's working on what |
|
||||||
|
| **Sprint Reports** | Filter by date range to generate sprint/release reports |
|
||||||
|
| **Cross-Repo View** | All repos' issues appear as tasks in a unified project board |
|
||||||
|
| **Executive Dashboards** | Project progress, overdue tasks, team workload at a glance |
|
||||||
|
|
||||||
|
### Recommended Sync Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Trigger Options"
|
||||||
|
CRON["Cron Job<br/>every 15 min"]
|
||||||
|
HOOK["Gitea Webhook<br/>on issue events"]
|
||||||
|
CLI["Manual CLI<br/>on-demand"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Sync Engine"
|
||||||
|
S["Sync Job"]
|
||||||
|
MAP["Label → Project<br/>Mapping"]
|
||||||
|
MATCH["URL-based Task<br/>Matching"]
|
||||||
|
DATE["Date Translation<br/>Created → Start<br/>Deadline → End<br/>Closed → Actual End"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Output"
|
||||||
|
OO["OnlyOffice Tasks"]
|
||||||
|
GANTT["Gantt Timeline"]
|
||||||
|
REP["PM Reports"]
|
||||||
|
end
|
||||||
|
|
||||||
|
CRON --> S
|
||||||
|
HOOK --> S
|
||||||
|
CLI --> S
|
||||||
|
|
||||||
|
S --> MAP
|
||||||
|
S --> MATCH
|
||||||
|
S --> DATE
|
||||||
|
|
||||||
|
MAP --> OO
|
||||||
|
MATCH --> OO
|
||||||
|
DATE --> OO
|
||||||
|
OO --> GANTT
|
||||||
|
OO --> REP
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Case: Task Lifecycle Management
|
||||||
|
|
||||||
|
### Creating Tasks with Full Metadata
|
||||||
|
|
||||||
```go
|
```go
|
||||||
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
task, _ := client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{
|
||||||
|
ProjectId: projectID,
|
||||||
|
Title: "Implement payment gateway",
|
||||||
|
Description: "Integrate Stripe API for subscription billing",
|
||||||
|
StartDate: onlyoffice.Time(time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)),
|
||||||
|
Deadline: onlyoffice.Time(time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)),
|
||||||
|
Priority: 1, // High
|
||||||
|
MilestoneId: milestoneID,
|
||||||
|
Notify: true,
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
### Tracking Task Progress
|
||||||
|
|
||||||
| Variable | Description |
|
```go
|
||||||
|---|---|
|
tasks, _ := client.GetTasks(onlyoffice.NewProjectGetTasksRequest(projectID))
|
||||||
| `ONLYOFFICE_URL` | OnlyOffice instance URL |
|
|
||||||
| `ONLYOFFICE_USER` | Login email or username |
|
open, closed := 0, 0
|
||||||
| `ONLYOFFICE_PASS` | Password |
|
var overdue []*onlyoffice.Task
|
||||||
|
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Status != nil && *t.Status == onlyoffice.ProjectTaskStatusClosed {
|
||||||
|
closed++
|
||||||
|
} else {
|
||||||
|
open++
|
||||||
|
if t.Deadline != nil && t.Deadline.Before(time.Now()) {
|
||||||
|
overdue = append(overdue, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Progress: %d/%d done (%.0f%%)\n", closed, open+closed,
|
||||||
|
float64(closed)/float64(open+closed)*100)
|
||||||
|
|
||||||
|
if len(overdue) > 0 {
|
||||||
|
fmt.Printf("⚠ %d overdue tasks:\n", len(overdue))
|
||||||
|
for _, t := range overdue {
|
||||||
|
fmt.Printf(" - %s (due: %s)\n", *t.Title, t.Deadline.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subtask Management
|
||||||
|
|
||||||
|
Tasks support subtasks for breaking work into smaller pieces:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Task struct {
|
||||||
|
ID *int `json:"id"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
StartDate *time.Time `json:"startDate"`
|
||||||
|
Deadline *time.Time `json:"deadline"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Priority *int `json:"priority"` // High=1, Normal=0, Low=-1
|
||||||
|
Status *ProjectTaskStatus `json:"status"` // Open=1, Closed=2
|
||||||
|
Subtasks []any `json:"subtasks"`
|
||||||
|
MilestoneID *int64 `json:"milestoneId"`
|
||||||
|
Responsibles []*User `json:"responsibles"` // Assigned team members
|
||||||
|
// ... timestamps, permissions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Reference
|
## API Reference
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
|
||||||
- `NewClient(credentials)` - Create a new client
|
| Function | Description |
|
||||||
- `GetEnvironmentCredentials()` - Load credentials from env vars
|
|---|---|
|
||||||
- `Auth(credentials)` - Authenticate and get token
|
| `NewClient(credentials)` | Create a new API client |
|
||||||
- `Query(request, result)` - Execute an API request
|
| `GetEnvironmentCredentials()` | Load from `ONLYOFFICE_*` env vars |
|
||||||
|
| `Auth(credentials)` | Authenticate and get token |
|
||||||
|
| `Query(request, result)` | Execute raw API request |
|
||||||
|
|
||||||
### Projects
|
### Projects
|
||||||
|
|
||||||
- `GetProjects()` - List all projects
|
| Method | Description |
|
||||||
- `CreateProject(req)` - Create a new project
|
|---|---|
|
||||||
- `UpdateProject(req)` - Update a project
|
| `GetProjects()` | List all projects |
|
||||||
- `DeleteProject(id)` - Delete a project
|
| `CreateProject(req)` | Create a new project |
|
||||||
- `GetProjectMilestones(project)` - Get milestones for a project
|
| `UpdateProject(req)` | Update project details |
|
||||||
|
| `DeleteProject(id)` | Delete a project |
|
||||||
|
| `GetProjectMilestones(project)` | Get milestones with task counts |
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
- `GetTasks(req)` - List tasks with filtering
|
| Method | Description |
|
||||||
- `CreateProjectTask(req)` - Create a new task
|
|---|---|
|
||||||
- `UpdateProjectTask(req)` - Update a task
|
| `GetTasks(req)` | List tasks with filtering |
|
||||||
|
| `CreateProjectTask(req)` | Create task with dates, priority, milestone |
|
||||||
|
| `UpdateProjectTask(req)` | Update title, status, dates, priority |
|
||||||
|
|
||||||
|
### Task Fields for Gantt
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `StartDate` | `*time.Time` | Gantt bar start |
|
||||||
|
| `Deadline` | `*time.Time` | Gantt bar end |
|
||||||
|
| `Status` | `ProjectTaskStatus` | Open (1) / Closed (2) |
|
||||||
|
| `Priority` | `*int` | High (1) / Normal (0) / Low (-1) |
|
||||||
|
| `MilestoneID` | `*int64` | Groups tasks under milestones |
|
||||||
|
| `Responsibles` | `[]*User` | Assigned team members |
|
||||||
|
| `Subtasks` | `[]any` | Sub-items within a task |
|
||||||
|
|
||||||
### Users
|
### Users
|
||||||
|
|
||||||
- `GetUsers()` - List all users
|
| Method | Description |
|
||||||
|
|---|---|
|
||||||
|
| `GetUsers()` | List all users with profiles |
|
||||||
|
|
||||||
|
### Helper Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|---|---|
|
||||||
|
| `Projects` | `[]*Project` with `.Get(title)` lookup |
|
||||||
|
| `Time` | `time.Time` wrapper with OnlyOffice JSON format |
|
||||||
|
| `Task.GetGiteaIssueLink()` | Extract Gitea URL from task description |
|
||||||
|
|
||||||
|
### Calendar, CRM, Subtasks, File upload (v0.2+)
|
||||||
|
|
||||||
|
Since v0.2 the library also exposes OnlyOffice Workspace surfaces beyond
|
||||||
|
Projects: Calendar events, CRM (Contacts, Companies, Opportunities, Cases,
|
||||||
|
Tasks, History notes) and opportunity file uploads. These helpers return
|
||||||
|
untyped `map[string]any` for flexibility; callers that need typed structs
|
||||||
|
should use the typed Project/Task API above.
|
||||||
|
|
||||||
|
```go
|
||||||
|
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||||
|
client.SetDefaults(onlyoffice.GetEnvironmentDefaults()) // optional
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Calendar
|
||||||
|
events, _ := client.ListEvents(ctx, "2025-01-01", "2025-12-31")
|
||||||
|
client.AddEvent(ctx, "", "Interview", "2025-06-10T10:00:00Z", "2025-06-10T11:00:00Z", "", false)
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
deals, total, _ := client.ListOpportunities(ctx, 50, 0)
|
||||||
|
company, _ := client.FindCompany(ctx, "ACME")
|
||||||
|
|
||||||
|
// Subtasks (form-encoded)
|
||||||
|
client.AddSubtask(ctx, "4242", "Prepare CV")
|
||||||
|
```
|
||||||
|
|
||||||
|
### oo-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
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ONLYOFFICE_URL` (or `ONLYOFFICE_HOST`) | OnlyOffice instance URL |
|
||||||
|
| `ONLYOFFICE_USER` (or `ONLYOFFICE_NAME`) | Login email or username |
|
||||||
|
| `ONLYOFFICE_PASS` (or `ONLYOFFICE_PASSWORD`) | Password |
|
||||||
|
| `ONLYOFFICE_CALENDAR_ID` | Default calendar id used when omitted (default `1`) |
|
||||||
|
| `ONLYOFFICE_PROJECT_ID` | Default project id used when omitted (default `33`) |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
| Example | Description |
|
||||||
|
|---|---|
|
||||||
|
| [basic](examples/basic/) | List projects and users |
|
||||||
|
| [calendar](examples/calendar/) | List calendars and events, create a new event |
|
||||||
|
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
|
||||||
|
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
|
||||||
|
| [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
|
||||||
|
|
||||||
|
| Library | Description |
|
||||||
|
|---|---|
|
||||||
|
| [go-gitea-helpers](https://github.com/eSlider/go-gitea-helpers) | Gitea pagination helpers for issue/repo fetching |
|
||||||
|
| [go-matrix-bot](https://github.com/eSlider/go-matrix-bot) | Matrix bot with OnlyOffice task creation from chat |
|
||||||
|
| [go-trade](https://github.com/eSlider/go-trade) | Unified trade data model across exchanges |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+78
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Package main shows how to list calendars and events, and create a new event
|
||||||
|
// via the OnlyOffice Calendar API.
|
||||||
|
//
|
||||||
|
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||||
|
// export ONLYOFFICE_USER="admin@example.com"
|
||||||
|
// export ONLYOFFICE_PASS="your-password"
|
||||||
|
// # optional: default calendar for AddEvent
|
||||||
|
// export ONLYOFFICE_CALENDAR_ID="42"
|
||||||
|
// go run ./examples/calendar
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
creds := onlyoffice.GetEnvironmentCredentials()
|
||||||
|
if creds.Url == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := onlyoffice.NewClient(creds)
|
||||||
|
client.SetDefaults(onlyoffice.GetEnvironmentDefaults())
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
start := time.Now().Format("2006-01-02")
|
||||||
|
end := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
|
||||||
|
|
||||||
|
calendars, err := client.ListCalendars(ctx, start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list calendars: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Calendars (%d):\n", len(calendars))
|
||||||
|
for _, cal := range calendars {
|
||||||
|
fmt.Printf(" - id=%v title=%v\n", cal["objectId"], cal["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := client.ListEvents(ctx, start, end)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list events: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nEvents in [%s..%s]: %d\n", start, end, len(events))
|
||||||
|
for _, e := range events {
|
||||||
|
fmt.Printf(" - %v @ %v\n", e["title"], e["start"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create demo event — uncomment to exercise.
|
||||||
|
// evStart := time.Now().Add(time.Hour).Format(time.RFC3339)
|
||||||
|
// evEnd := time.Now().Add(2 * time.Hour).Format(time.RFC3339)
|
||||||
|
// created, err := client.AddEvent(ctx, "", "Library demo", evStart, evEnd, "via examples/calendar", false)
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatalf("add event: %v", err)
|
||||||
|
// }
|
||||||
|
// fmt.Printf("created event id=%v\n", created["objectId"])
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package main lists CRM contacts and opportunities, and shows how to create
|
||||||
|
// and remove a demo company + opportunity + history note in OnlyOffice CRM.
|
||||||
|
//
|
||||||
|
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||||
|
// export ONLYOFFICE_USER="admin@example.com"
|
||||||
|
// export ONLYOFFICE_PASS="your-password"
|
||||||
|
// go run ./examples/crm
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
creds := onlyoffice.GetEnvironmentCredentials()
|
||||||
|
if creds.Url == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := onlyoffice.NewClient(creds)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
contacts, total, err := client.ListContacts(ctx, 10, 0, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list contacts: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("CRM contacts: %d (total=%d)\n", len(contacts), total)
|
||||||
|
for _, c := range contacts {
|
||||||
|
fmt.Printf(" - id=%v name=%v\n", c["id"], c["displayName"])
|
||||||
|
}
|
||||||
|
|
||||||
|
opps, oppTotal, err := client.ListOpportunities(ctx, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list opportunities: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nOpportunities: %d (total=%d)\n", len(opps), oppTotal)
|
||||||
|
for _, o := range opps {
|
||||||
|
fmt.Printf(" - id=%v title=%v\n", o["id"], o["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full create/attach/delete cycle — uncomment to exercise.
|
||||||
|
// company, err := client.CreateCompany(ctx, "Demo GmbH")
|
||||||
|
// if err != nil { log.Fatalf("create company: %v", err) }
|
||||||
|
// cid := fmt.Sprint(company["id"])
|
||||||
|
// defer client.DeleteContact(ctx, cid)
|
||||||
|
//
|
||||||
|
// stages, err := client.ListDealStages(ctx)
|
||||||
|
// if err != nil || len(stages) == 0 { log.Fatalf("no deal stages: %v", err) }
|
||||||
|
// stageID := int(stages[0]["id"].(float64))
|
||||||
|
//
|
||||||
|
// opp, err := client.CreateOpportunity(ctx, "Demo opp", stageID, "", "EUR", "from examples/crm", 0)
|
||||||
|
// if err != nil { log.Fatalf("create opportunity: %v", err) }
|
||||||
|
// oid := fmt.Sprint(opp["id"])
|
||||||
|
// _, _ = client.AddOpportunityMember(ctx, oid, cid)
|
||||||
|
// _, _ = client.AddHistoryNote(ctx, "opportunity", int(opp["id"].(float64)), "Touched via library example", 0)
|
||||||
|
// _, _ = client.DeleteOpportunity(ctx, oid)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Package main creates a parent task and attaches two subtasks using the
|
||||||
|
// project-tasks API (POST /api/2.0/project/task/{id}.json).
|
||||||
|
//
|
||||||
|
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||||
|
// export ONLYOFFICE_USER="admin@example.com"
|
||||||
|
// export ONLYOFFICE_PASS="your-password"
|
||||||
|
// export ONLYOFFICE_PROJECT_ID="123"
|
||||||
|
// go run ./examples/subtasks
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
creds := onlyoffice.GetEnvironmentCredentials()
|
||||||
|
defaults := onlyoffice.GetEnvironmentDefaults()
|
||||||
|
if creds.Url == "" || defaults.ProjectID == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL and ONLYOFFICE_PROJECT_ID must be set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := onlyoffice.NewClient(creds)
|
||||||
|
client.SetDefaults(defaults)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
parent, err := client.AddTask(ctx, defaults.ProjectID, "Example parent", "Created by examples/subtasks", 0, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("add parent task: %v", err)
|
||||||
|
}
|
||||||
|
parentID := fmt.Sprint(parent["id"])
|
||||||
|
fmt.Printf("parent task id=%s\n", parentID)
|
||||||
|
|
||||||
|
for _, title := range []string{"first subtask", "second subtask"} {
|
||||||
|
st, err := client.AddSubtask(ctx, parentID, title)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("add subtask %q: %v", title, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" + subtask id=%v title=%v\n", st["id"], st["title"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup — uncomment once you verified the subtasks in the OnlyOffice UI.
|
||||||
|
// if _, err := client.DeleteTask(ctx, parentID); err != nil {
|
||||||
|
// log.Printf("cleanup: %v", err)
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Unit tests for the form/multipart helpers and a few CRM/Calendar/Subtask
|
||||||
|
// methods. These do not require a real OnlyOffice server — they spin up a
|
||||||
|
// net/http/httptest.Server that emulates the expected endpoints.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeServer wires a minimal OnlyOffice-like API: it always returns the same
|
||||||
|
// token on /api/2.0/authentication.json and dispatches remaining requests via
|
||||||
|
// the caller-supplied handler.
|
||||||
|
func fakeServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":{"token":"TEST_TOKEN","expires":"2099-01-01T00:00:00Z"}}`)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/", h)
|
||||||
|
return httptest.NewServer(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestClient builds a Client pointed at the test server with a primed token
|
||||||
|
// so helpers skip the auth round-trip when the test does not exercise it.
|
||||||
|
func newTestClient(srv *httptest.Server) *Client {
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
c.token = &Token{Value: "TEST_TOKEN", Expires: Time(time.Now().Add(time.Hour))}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddSubtaskFormEncoded(t *testing.T) {
|
||||||
|
var gotPath, gotAuth, gotCT, gotBody string
|
||||||
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
gotAuth = r.Header.Get("Authorization")
|
||||||
|
gotCT = r.Header.Get("Content-Type")
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":{"id":99,"title":"child"}}`)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(srv)
|
||||||
|
|
||||||
|
out, err := c.AddSubtask(context.Background(), "42", "child")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddSubtask: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/2.0/project/task/42.json" {
|
||||||
|
t.Errorf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotAuth != "TEST_TOKEN" {
|
||||||
|
t.Errorf("auth header = %q", gotAuth)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(gotCT, "application/x-www-form-urlencoded") {
|
||||||
|
t.Errorf("content-type = %q", gotCT)
|
||||||
|
}
|
||||||
|
if gotBody != "title=child" {
|
||||||
|
t.Errorf("body = %q", gotBody)
|
||||||
|
}
|
||||||
|
if fm, ok := out["title"].(string); !ok || fm != "child" {
|
||||||
|
t.Errorf("out.title = %v", out["title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEventUsesDefaultCalendar(t *testing.T) {
|
||||||
|
var gotPath, gotBody string
|
||||||
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
gotBody = string(b)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":[{"id":"e1","name":"hi"}]}`)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(srv)
|
||||||
|
c.SetDefaults(Defaults{CalendarID: "7"})
|
||||||
|
|
||||||
|
ev, err := c.AddEvent(context.Background(), "", "hi", "2025-01-01T10:00:00Z", "2025-01-01T11:00:00Z", "desc", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddEvent: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/api/2.0/calendar/7/event.json" {
|
||||||
|
t.Errorf("path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotBody, "name=hi") || !strings.Contains(gotBody, "isAllDayLong=false") {
|
||||||
|
t.Errorf("body = %q", gotBody)
|
||||||
|
}
|
||||||
|
if ev["id"] != "e1" {
|
||||||
|
t.Errorf("event id = %v", ev["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEventRequiresCalendar(t *testing.T) {
|
||||||
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("server must not be hit without calendar id")
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(srv)
|
||||||
|
if _, err := c.AddEvent(context.Background(), "", "t", "s", "e", "", false); err == nil {
|
||||||
|
t.Fatal("expected error when no calendar id and no default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListContactsTotal(t *testing.T) {
|
||||||
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/2.0/crm/contact/filter.json" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("filterValue") != "acme" {
|
||||||
|
t.Errorf("filterValue = %q", r.URL.Query().Get("filterValue"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":[{"id":1,"displayName":"ACME"}],"total":7}`)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(srv)
|
||||||
|
|
||||||
|
items, total, err := c.ListContacts(context.Background(), 50, 0, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListContacts: %v", err)
|
||||||
|
}
|
||||||
|
if total != 7 {
|
||||||
|
t.Errorf("total = %d", total)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("len(items) = %d", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadOpportunityFileMultipart(t *testing.T) {
|
||||||
|
tmp := t.TempDir() + "/a.txt"
|
||||||
|
if err := writeFile(tmp, "hello"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var gotField, gotFilename, gotBody string
|
||||||
|
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
t.Errorf("content-type = %q", ct)
|
||||||
|
}
|
||||||
|
_, params, err := mime.ParseMediaType(ct)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mr := multipart.NewReader(r.Body, params["boundary"])
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotField = part.FormName()
|
||||||
|
gotFilename = part.FileName()
|
||||||
|
b, _ := io.ReadAll(part)
|
||||||
|
gotBody = string(b)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":{"id":1,"title":"a.txt"}}`)
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
c := newTestClient(srv)
|
||||||
|
|
||||||
|
out, err := c.UploadOpportunityFile(context.Background(), "123", tmp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadOpportunityFile: %v", err)
|
||||||
|
}
|
||||||
|
if gotField != "file" || gotFilename != "a.txt" || gotBody != "hello" {
|
||||||
|
t.Errorf("multipart = field=%q filename=%q body=%q", gotField, gotFilename, gotBody)
|
||||||
|
}
|
||||||
|
if out["title"] != "a.txt" {
|
||||||
|
t.Errorf("out.title = %v", out["title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path, content string) error {
|
||||||
|
return os.WriteFile(path, []byte(content), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseFieldMissing(t *testing.T) {
|
||||||
|
_, err := responseField(json.RawMessage(`{"other":1}`), "response")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on missing field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateContextUsesCacheWhenFresh(t *testing.T) {
|
||||||
|
var authHits int
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHits++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":{"token":"FRESH_TOKEN","expires":"2099-01-01T00:00:00.0000000-00:00"}}`)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
|
||||||
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
||||||
|
t.Fatalf("first AuthenticateContext: %v", err)
|
||||||
|
}
|
||||||
|
if authHits != 1 {
|
||||||
|
t.Errorf("expected 1 auth hit after first call, got %d", authHits)
|
||||||
|
}
|
||||||
|
if c.token == nil || c.token.Value != "FRESH_TOKEN" {
|
||||||
|
t.Errorf("token not cached: %+v", c.token)
|
||||||
|
}
|
||||||
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
||||||
|
t.Fatalf("second AuthenticateContext: %v", err)
|
||||||
|
}
|
||||||
|
if authHits != 1 {
|
||||||
|
t.Errorf("cache bypassed: expected 1 auth hit, got %d", authHits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidateTokenForcesReauth(t *testing.T) {
|
||||||
|
var authHits int
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHits++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"response":{"token":"T","expires":"2099-01-01T00:00:00.0000000-00:00"}}`)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
|
||||||
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
c.InvalidateToken()
|
||||||
|
if c.token != nil {
|
||||||
|
t.Fatalf("token still cached after Invalidate: %+v", c.token)
|
||||||
|
}
|
||||||
|
if err := c.AuthenticateContext(context.Background()); err != nil {
|
||||||
|
t.Fatalf("re-auth: %v", err)
|
||||||
|
}
|
||||||
|
if authHits != 2 {
|
||||||
|
t.Errorf("expected 2 auth hits after invalidate, got %d", authHits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateContextRespectsCancellation(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
_, _ = io.WriteString(w, `{"response":{"token":"T","expires":"2099-01-01T00:00:00.0000000-00:00"}}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if err := c.AuthenticateContext(ctx); err == nil {
|
||||||
|
t.Fatal("expected error on context timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// 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]))
|
||||||
|
}
|
||||||
|
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?=\n## |\z)`).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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+57
-6
@@ -22,14 +22,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 +100,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
|
||||||
|
|||||||
+169
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user