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

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

Made-with: Cursor
2026-04-24 11:30:11 +01:00

702 lines
21 KiB
Go

package onlyoffice
// OnlyOffice client package
import (
"encoding/json"
"fmt"
"github.com/google/go-querystring/query"
"io"
"net/http"
"os"
regexp "regexp"
"strings"
"time"
)
// NewClient API
func NewClient(c Credentials) *Client {
return &Client{
client: http.DefaultClient,
credentials: &c,
}
}
// Defaults holds optional fallbacks used by package-level helpers when callers
// pass an empty identifier (calendar or project). Set via (*Client).SetDefaults.
type Defaults struct {
CalendarID string
ProjectID string
}
// SetDefaults configures optional identifiers used as fallbacks by methods such
// as AddEvent (when calendarID == "") or AddTask (when projectID == "").
func (c *Client) SetDefaults(d Defaults) { c.defaults = d }
// firstNonEmpty returns the first non-empty trimmed value, or "" if none found.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// GetEnvironmentCredentials reads OnlyOffice credentials from environment.
//
// Primary variables (documented):
// - ONLYOFFICE_URL
// - ONLYOFFICE_USER
// - ONLYOFFICE_PASS
//
// Additional aliases accepted for interoperability with sibling tools:
// - ONLYOFFICE_HOST (alias for ONLYOFFICE_URL)
// - ONLYOFFICE_NAME (alias for ONLYOFFICE_USER)
// - ONLYOFFICE_PASSWORD (alias for ONLYOFFICE_PASS)
func GetEnvironmentCredentials() Credentials {
url := firstNonEmpty(os.Getenv("ONLYOFFICE_URL"), os.Getenv("ONLYOFFICE_HOST"))
url = strings.TrimRight(url, "/")
return Credentials{
Url: url,
User: firstNonEmpty(os.Getenv("ONLYOFFICE_USER"), os.Getenv("ONLYOFFICE_NAME")),
Password: firstNonEmpty(os.Getenv("ONLYOFFICE_PASS"), os.Getenv("ONLYOFFICE_PASSWORD")),
}
}
// GetEnvironmentDefaults reads optional library defaults from environment:
//
// - ONLYOFFICE_CALENDAR_ID (default: "1")
// - ONLYOFFICE_PROJECT_ID (alias: ONLYOFFICE_CALENDAR_PROJECT_ID; default: "33")
func GetEnvironmentDefaults() Defaults {
return Defaults{
CalendarID: firstNonEmpty(os.Getenv("ONLYOFFICE_CALENDAR_ID"), "1"),
ProjectID: firstNonEmpty(
os.Getenv("ONLYOFFICE_PROJECT_ID"),
os.Getenv("ONLYOFFICE_CALENDAR_PROJECT_ID"),
"33",
),
}
}
// Credentials of OnlyOffice User
type Credentials struct {
Url string `json:"-"`
User string `json:"userName"`
Password string `json:"password"`
}
// ToJson for Credentials
func (c Credentials) ToJson() []byte {
b, err := json.Marshal(c)
if err != nil {
return nil
}
return b
}
// Client of OnlyOffice API uses credentials to get a token and query the API by every request
type Client struct {
client *http.Client // HTTP client
credentials *Credentials // OnlyOffice credentials
token *Token // Authentication token
defaults Defaults // optional fallbacks for calendar/project IDs
selfID string // cached /api/2.0/people/@self id
noteCatID int // cached CRM history category id for "note"
}
// MetaResponse Response
type MetaResponse struct {
Count int `json:"count"`
Total int `json:"total"`
Status int `json:"status"`
StatusCode int `json:"statusCode"`
}
type Permissions struct {
CanEdit *bool `json:"canEdit,omitempty"` // Is user can edit
CanDelete *bool `json:"canDelete,omitempty"` // Is user can delete
}
// Project struct
type Project struct {
ID *int `json:"id"`
Title *string `json:"title"`
Security map[string]bool `json:"security,omitempty"`
ProjectFolder *json.Number `json:"projectFolder,omitempty"`
Description *string `json:"description"`
Status *int `json:"status"`
ResponsibleID *string `json:"responsibleId,omitempty"`
Responsible *User `json:"responsible,omitempty"`
IsPrivate *bool `json:"isPrivate"`
TaskCount *int `json:"taskCount,omitempty"`
TaskCountTotal *int `json:"taskCountTotal,omitempty"`
MilestoneCount *int `json:"milestoneCount,omitempty"`
DiscussionCount *int `json:"discussionCount,omitempty"`
ParticipantCount *int `json:"participantCount,omitempty"`
TimeTrackingTotal *string `json:"timeTrackingTotal,omitempty"`
DocumentsCount *int `json:"documentsCount,omitempty"`
IsFollow *bool `json:"isFollow,omitempty"`
Created *time.Time `json:"created"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById"`
Updated *time.Time `json:"updated"`
UpdatedByID *string `json:"updatedById"`
Permissions *Permissions `json:",inline,omitempty"`
}
type Milestone struct {
ID *int64 `json:"id,omitempty"`
Description *string `json:"description,omitempty"`
Title *string `json:"title,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
IsKey *bool `json:"isKey,omitempty"`
IsNotify *bool `json:"isNotify,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Responsible *User `json:"responsible,omitempty"`
ActiveTaskCount *int64 `json:"activeTaskCount,omitempty"`
ClosedTaskCount *int64 `json:"closedTaskCount,omitempty"`
Status *int64 `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
*Permissions `json:",inline,omitempty"`
}
type User struct {
ID *string `json:"id,omitempty"`
UserName *string `json:"userName,omitempty"`
IsVisitor *bool `json:"isVisitor,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Email *string `json:"email,omitempty"`
Status *int `json:"status,omitempty"`
ActivationStatus *int `json:"activationStatus,omitempty"`
Terminated any `json:"terminated,omitempty"`
Department *string `json:"department,omitempty"`
WorkFrom *time.Time `json:"workFrom,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
AvatarMedium *string `json:"avatarMedium,omitempty"`
Avatar *string `json:"avatar,omitempty"`
IsAdmin *bool `json:"isAdmin,omitempty"`
IsLDAP *bool `json:"isLDAP,omitempty"`
ListAdminModules []string `json:"listAdminModules,omitempty"`
IsOwner *bool `json:"isOwner,omitempty"`
CultureName *string `json:"cultureName,omitempty"`
IsSSO *bool `json:"isSSO,omitempty"`
AvatarSmall *string `json:"avatarSmall,omitempty"`
QuotaLimit *int `json:"quotaLimit,omitempty"`
UsedSpace *int `json:"usedSpace,omitempty"`
DocsSpace *int `json:"docsSpace,omitempty"`
MailSpace *int `json:"mailSpace,omitempty"`
TalkSpace *int `json:"talkSpace,omitempty"`
ProfileURL *string `json:"profileUrl,omitempty"`
Title *string `json:"title,omitempty"`
Sex *string `json:"sex,omitempty"`
Lead *string `json:"lead,omitempty"`
Birthday *time.Time `json:"birthday,omitempty"`
Location *string `json:"location,omitempty"`
Notes *string `json:"notes,omitempty"`
Contacts []Contact `json:"contacts,omitempty"`
Groups []Group `json:"groups,omitempty"`
}
type Contact struct {
Type *string `json:"type,omitempty"`
Value *string `json:"value,omitempty"`
}
type Group struct {
ID *string `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
Manager interface{} `json:"manager,omitempty"`
}
type Task struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
StartDate *time.Time `json:"startDate,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
Description *string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"`
ProjectOwner *ProjectOwner `json:"projectOwner,omitempty"`
Subtasks []any `json:"subtasks,omitempty"`
Status *ProjectTaskStatus `json:"status,omitempty"`
Created *time.Time `json:"created,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
CreatedByID *string `json:"createdById,omitempty"` // UUID
Updated *time.Time `json:"updated,omitempty"`
UpdatedBy *User `json:"updatedBy,omitempty"`
UpdatedById *string `json:"updatedById,omitempty"` // UUID
Responsibles []*User `json:"responsibles,omitempty"`
ResponsibleIDS []string `json:"responsibleIds,omitempty"` // UUID list
CanEdit *bool `json:"canEdit,omitempty"`
CanCreateSubtask *bool `json:"canCreateSubtask,omitempty"`
CanCreateTimeSpend *bool `json:"canCreateTimeSpend,omitempty"`
CanDelete *bool `json:"canDelete,omitempty"`
CanReadFiles *bool `json:"canReadFiles,omitempty"`
MilestoneID *int64 `json:"milestoneId,omitempty"`
Milestone *Milestone `json:"milestone,omitempty"`
}
var GiteaIssue2OnlyOfficeMappingRegExp = regexp.MustCompile(`URL:(.*)$`)
var TestReg = `
URL:https://git.markets-platform.com/TradePlatform/email-templates/issues/1`
// GetGiteaIssueLink from task description
func (t *Task) GetGiteaIssueLink() string {
// Get issue from description
if t.Description != nil {
var match = GiteaIssue2OnlyOfficeMappingRegExp.FindStringSubmatch(*t.Description)
if len(match) > 1 {
return strings.TrimSpace(match[1])
}
}
return ""
}
// TaskPriority High = 1, Normal = 0, Low = -1
type TaskPriority int
const (
TaskPriorityHigh TaskPriority = 1 // High
TaskPriorityNormal TaskPriority = 0 // Normal
TaskPriorityLow TaskPriority = -1
)
type ProjectOwner struct {
ID *int `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Status *int `json:"status,omitempty"`
IsPrivate *bool `json:"isPrivate,omitempty"`
}
// String for Project to return title
func (p Project) String() string {
return fmt.Sprintf(*p.Title)
}
// Token OnlyOffice
type Token struct {
Value string `json:"token"`
Expires Time `json:"expires"`
}
// Time for OnlyOffice
type Time time.Time
// To String for Time
func (t Time) String() string {
return time.Time(t).Format("2006-01-02T15:04:05")
}
func (t Time) Before(u Time) bool {
return time.Time(t).Before(time.Time(u))
}
func (t Time) After(u Time) bool {
return time.Time(t).After(time.Time(u))
}
// UnmarshalJSON for Time
func (r *Time) UnmarshalJSON(data []byte) error {
// Trim quotes
data = data[1 : len(data)-1]
t, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", string(data))
if err != nil {
return err
}
*r = Time(t)
return nil
}
// Request for OnlyOffice API
type Request struct {
Uri string // URI is the path to the API endpoint e.g. /api/2.0/project.json
Method string // Method is the HTTP method e.g. GET, POST, PUT, DELETE
Params any // Params is the query parameters
Body any // Body could be a struct, map, []byte, or string. It will be marshaled to JSON
Token *string // Token is the Authorization header, if not set then it will get a token
NoAuth bool // NoAuth for skipping automatic authentication
Debug bool
}
// GetMethod for Request
func (r Request) GetMethod() string {
if r.Method == "" {
return "GET"
}
return r.Method
}
// Query the OnlyOffice API
// - If request.Method is not set then it will default to GET
// - If request.Code is not nil then it will be marshaled to JSON
// - If request.Token is nil then it will get a token
// - If request.Token is not nil then it will be used as Authorization header
// - If request.NoAuth is true then it will skip automatic authentication
func (c *Client) Query(request Request, result interface{}) (err error) {
var url = fmt.Sprintf("%s%s", c.credentials.Url, request.Uri)
var rdr io.Reader = nil
var jsonRequestBody string
// Add query parameters if available
if request.Params != nil {
v, err := query.Values(request.Params)
if err != nil {
return err
}
url = fmt.Sprintf("%s?%s", url, v.Encode())
}
// Create request
if request.Body != nil {
// Check request body if type is not []byte or string the marshal it to JSON
switch request.Body.(type) {
case []byte:
jsonRequestBody = string(request.Body.([]byte))
rdr = strings.NewReader(string(request.Body.([]byte)))
case string:
jsonRequestBody = request.Body.(string)
rdr = strings.NewReader(request.Body.(string))
default:
// Marshal to JSON
b, err := json.Marshal(request.Body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %v", err)
}
jsonRequestBody = string(b)
rdr = strings.NewReader(string(b))
}
}
req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache")
// Get token if not set?
if !request.NoAuth {
// Get token if not set or expired
if c.token == nil || time.Time(c.token.Expires).Before(time.Now()) {
c.token, err = c.Auth(c.credentials)
if err != nil {
return fmt.Errorf("failed to authenticate: %v", err)
}
}
}
// Set token if available
if request.Token != nil {
req.Header.Set("Authorization", *request.Token)
} else {
// Set token from a client if available
if c.token != nil {
req.Header.Set("Authorization", c.token.Value)
}
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
if result == nil {
return nil
}
if request.Debug {
// Rewind reader
_ = jsonRequestBody
var buf = new(strings.Builder)
io.Copy(buf, resp.Body)
js := buf.String()
return json.Unmarshal([]byte(js), result)
} else {
// Unmarshal response using reader
return json.NewDecoder(resp.Body).Decode(result)
}
}
// Auth to authenticate by getting a token using credentials
func (c *Client) Auth(creds *Credentials) (t *Token, err error) {
t = &Token{}
return t, c.Query(Request{
Uri: "/api/2.0/authentication.json",
Method: "POST",
Body: creds,
NoAuth: true,
}, &struct { // Unmarshal response into a struct
MetaResponse `json:",inline"`
Response *Token `json:"response"`
}{
Response: t,
})
}
// Projects list
type Projects []*Project
// Get Project by title
func (p Projects) Get(title string) *Project {
for _, prj := range p {
if *prj.Title == title {
return prj
}
}
return nil
}
// GetProjects
// - Get all projects, not excluding private projects
func (c *Client) GetProjects() (list Projects, err error) {
return list, c.Query(Request{Uri: `/api/2.0/project/filter.json?simple=true`},
&struct {
MetaResponse `json:",inline"`
Response *Projects
}{Response: &list})
}
// GetProjectMilestones Get project milestones
// https://api1.onlyoffice.com/portals/method/project/post/api/2.0/project/%7bid%7d/milestone
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID), Debug: false},
&struct {
MetaResponse `json:",inline"`
//Response *[]*Milestone
Response *[]*Milestone
}{Response: &list})
return list, err
}
// GetUsers List users
func (c *Client) GetUsers() (list []*User, err error) {
return list, c.Query(Request{Uri: "/api/2.0/people/filter.json"},
&struct {
MetaResponse `json:",inline"`
Response *[]*User `json:"response"`
}{Response: &list})
}
// NewProjectRequest
type NewProjectRequest struct {
Title string `json:"title"`
Description string `json:"description"`
ResponsibleID string `json:"responsibleId"`
}
// ProjectUpdateRequest
type ProjectUpdateRequest struct {
ID int `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ResponsibleID string `json:"responsibleId,omitempty"`
}
// NewProjectTaskRequest
type NewProjectTaskRequest struct {
Title string `url:"title"`
Description string `url:"description"`
Notify bool `url:"notify"`
MilestoneId int `url:"milestoneId"`
Priority int `url:"priority"`
ProjectId int `url:"projectId"`
StartDate Time `url:"startDate"` // 2024-04-16T00:00:00
Deadline Time `url:"deadline"` // 2024-04-20T00:00:00
Status ProjectTaskStatus
}
type ProjectTaskStatus int
const (
ProjectTaskStatusNotAccept ProjectTaskStatus = 0
ProjectTaskStatusOpen ProjectTaskStatus = 1
ProjectTaskStatusClosed ProjectTaskStatus = 2
ProjectTaskStatusDisable ProjectTaskStatus = 3
ProjectTaskStatusUnclassified ProjectTaskStatus = 4
ProjectTaskStatusNotInMilestone ProjectTaskStatus = 5
)
// ProjectTaskUpdateRequest - Update a task
// See: https://api1.onlyoffice.com/portals/method/project/put/api/2.0/project/task/%7btaskid%7d
type ProjectTaskUpdateRequest struct {
ID int `json:"id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Priority *int `json:"priority,omitempty"` // optional, New task priority {"0"} High = 1, Normal = 0, Low = -1
StartDate *Time `json:"startDate,omitempty"`
Deadline *Time `json:"deadline,omitempty"`
//Deadline *Time `json:"closed,omitempty"`
ProjectID *int64 `json:"projectID,omitempty"` // New task project ID (optional)
MilestoneId *int64 `json:"milestoneid,omitempty"`
Responsible []string `json:"responsibles,omitempty"` // New list of task responsibles {"9924256A-739C-462b-AF15-E652A3B1B6EB"}
Notify bool `json:"notify,omitempty"`
Status ProjectTaskStatus `json:"status,omitempty"`
//Progress int64 `json:"progress,omitempty"` // New task progress
}
// MarshalJSON to quote Time as string
func (t Time) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(
`"%s"`,
time.Time(t).Format("2006-01-02T15:04:05"),
)), nil
}
// CreateProject with a new project request
// - if responsibleID is empty then it will use the first user as responsible
// - if responsibleID is empty and user is not found then it will use the current user
func (c *Client) CreateProject(np NewProjectRequest) (prj *Project, err error) {
// We need a responsible user
if np.ResponsibleID == "" {
var users []*User
users, err = c.GetUsers()
if err != nil {
return
}
// Find user by email
for _, user := range users {
if *user.Email == c.credentials.User {
np.ResponsibleID = *user.ID
break
}
}
// Has no user?
if np.ResponsibleID == "" {
// Set first user as responsible
np.ResponsibleID = *users[0].ID
}
}
prj = new(Project)
return prj, c.Query(Request{
Uri: "/api/2.0/project.json",
Method: "POST",
Body: np,
}, &struct {
MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{
Response: prj,
})
}
func (c *Client) CreateProjectTask(req NewProjectTaskRequest) (task *Task, err error) {
task = &Task{}
return task, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/task.json", req.ProjectId),
Method: "POST",
Body: req,
}, &struct {
//MetaResponse `json:",inline"`
Response *Task `json:"response"`
}{task})
}
func (c *Client) DeleteProject(id int) (p *Project, err error) {
p = &Project{}
return p, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", id),
Method: "DELETE"},
&struct {
//MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{p})
}
func (c *Client) UpdateProject(req ProjectUpdateRequest) (p *Project, err error) {
p = &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d.json", req.ID),
Method: "PUT",
Body: req,
},
&struct {
//MetaResponse `json:",inline"`
Response *Project `json:"response"`
}{p})
}
func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (task *Task, err error) {
task = &Task{}
return task, c.Query(
Request{
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT",
Body: req,
Debug: true,
}, &struct {
//MetaResponse `json:",inline"`
Response *Task `json:"response"`
}{task})
}
// ProjectGetTasksRequest
// See: https://api.onlyoffice.com/workspace/api-backend/usage-api/project/tasks/get-tasks-by-status/
type ProjectGetTasksRequest struct {
ProjectId int `url:"projectId"`
Count int `url:"count"`
StartIndex int `url:"startIndex"`
SortBy string `url:"sortBy"`
SortOrder string `url:"sortOrder"`
Simple bool `url:"simple"`
}
// NewProjectGetTasksRequest to get a simple task list
func NewProjectGetTasksRequest(projectId int) ProjectGetTasksRequest {
return ProjectGetTasksRequest{
ProjectId: projectId,
Count: 1000,
StartIndex: 0,
SortBy: "title",
SortOrder: "ascending",
Simple: true,
}
}
// GetTasks returns a list of all the tasks from a project with the ID specified in the request.
func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error) {
return tasks, c.Query(
Request{
Uri: "/api/2.0/project/task/filter.json",
Params: req,
Debug: true,
},
&struct {
//MetaResponse `json:",inline"`
Response *[]*Task `json:"response"`
}{&tasks})
}