Initial release: OnlyOffice Project Management API client
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Andriy Oblivantsev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# go-onlyoffice
|
||||||
|
|
||||||
|
Go client library for the [OnlyOffice](https://www.onlyoffice.com/) Project Management API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Token-based authentication with automatic renewal
|
||||||
|
- Project CRUD operations (create, read, update, delete)
|
||||||
|
- Task management (create, update, list, filter)
|
||||||
|
- Milestone management
|
||||||
|
- User listing
|
||||||
|
- Query parameter serialization via struct tags
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/eslider/go-onlyoffice
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := onlyoffice.NewClient(onlyoffice.Credentials{
|
||||||
|
Url: "https://your-onlyoffice.example.com",
|
||||||
|
User: "admin@example.com",
|
||||||
|
Password: "your-password",
|
||||||
|
})
|
||||||
|
|
||||||
|
// List projects
|
||||||
|
projects, err := client.GetProjects()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
fmt.Printf("Project: %s (ID: %d)\n", *p.Title, *p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or load credentials from environment variables:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client := onlyoffice.NewClient(onlyoffice.GetEnvironmentCredentials())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `ONLYOFFICE_URL` | OnlyOffice instance URL |
|
||||||
|
| `ONLYOFFICE_USER` | Login email or username |
|
||||||
|
| `ONLYOFFICE_PASS` | Password |
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
- `NewClient(credentials)` - Create a new client
|
||||||
|
- `GetEnvironmentCredentials()` - Load credentials from env vars
|
||||||
|
- `Auth(credentials)` - Authenticate and get token
|
||||||
|
- `Query(request, result)` - Execute an API request
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
- `GetProjects()` - List all projects
|
||||||
|
- `CreateProject(req)` - Create a new project
|
||||||
|
- `UpdateProject(req)` - Update a project
|
||||||
|
- `DeleteProject(id)` - Delete a project
|
||||||
|
- `GetProjectMilestones(project)` - Get milestones for a project
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
- `GetTasks(req)` - List tasks with filtering
|
||||||
|
- `CreateProjectTask(req)` - Create a new task
|
||||||
|
- `UpdateProjectTask(req)` - Update a task
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
- `GetUsers()` - List all users
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
func init() {
|
||||||
|
// Load from .env file in the project root
|
||||||
|
_ = godotenv.Load(".env")
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipWithoutCredentials(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("ONLYOFFICE_URL") == "" {
|
||||||
|
t.Skip("ONLYOFFICE_URL is not set, skipping integration test")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient(t *testing.T) {
|
||||||
|
skipWithoutCredentials(t)
|
||||||
|
|
||||||
|
// Create a new OnlyOffice client
|
||||||
|
credentials := GetEnvironmentCredentials()
|
||||||
|
client := NewClient(credentials)
|
||||||
|
var token *Token
|
||||||
|
var err error
|
||||||
|
token, err = client.Auth(&credentials)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get token: %v", err)
|
||||||
|
}
|
||||||
|
if token == nil {
|
||||||
|
t.Error("Value is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up: remove all projects start with "Test project"
|
||||||
|
projects, err := client.GetProjects()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get projects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, project := range projects {
|
||||||
|
if strings.HasPrefix(*project.Title, "Test project") {
|
||||||
|
prjStatus, err := client.DeleteProject(*project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to delete project: %v", err)
|
||||||
|
}
|
||||||
|
if *prjStatus.ID != *project.ID {
|
||||||
|
t.Errorf("Project ID is not equal: %v != %v", prjStatus.ID, project.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test create project
|
||||||
|
project, err := client.CreateProject(NewProjectRequest{
|
||||||
|
Title: "Test project",
|
||||||
|
Description: "Test project description",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to create project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project
|
||||||
|
project, err = client.UpdateProject(ProjectUpdateRequest{
|
||||||
|
ID: *project.ID,
|
||||||
|
Title: "Test project updated",
|
||||||
|
ResponsibleID: *project.Responsible.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
var task *Task
|
||||||
|
// Test create project task
|
||||||
|
task, err = client.CreateProjectTask(NewProjectTaskRequest{
|
||||||
|
ProjectId: *project.ID,
|
||||||
|
Title: "Test task",
|
||||||
|
Description: "Test task description",
|
||||||
|
Notify: true,
|
||||||
|
MilestoneId: 0,
|
||||||
|
Priority: 0,
|
||||||
|
// Deadline +2 days
|
||||||
|
StartDate: Time(time.Now().AddDate(0, 0, -2)),
|
||||||
|
Deadline: Time(time.Now().AddDate(0, 0, 2)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update project task
|
||||||
|
startDate := Time(time.Now().AddDate(0, 0, -14))
|
||||||
|
deadline := Time(time.Now().AddDate(0, 0, 3))
|
||||||
|
task, err = client.UpdateProjectTask(ProjectTaskUpdateRequest{
|
||||||
|
ID: *task.ID,
|
||||||
|
Title: "Test task updated",
|
||||||
|
Description: "Test task description updated",
|
||||||
|
StartDate: &startDate,
|
||||||
|
Deadline: &deadline,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task == nil {
|
||||||
|
t.Error("Value is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
prj, err := client.DeleteProject(*project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to delete project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *prj.ID != *project.ID {
|
||||||
|
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test list projects
|
||||||
|
projects, err = client.GetProjects()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get projects: %v", err)
|
||||||
|
}
|
||||||
|
if len(projects) == 0 {
|
||||||
|
t.Error("Value is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Package main demonstrates basic usage of the onlyoffice client library.
|
||||||
|
//
|
||||||
|
// Set environment variables before running:
|
||||||
|
//
|
||||||
|
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
|
||||||
|
// export ONLYOFFICE_USER="admin@example.com"
|
||||||
|
// export ONLYOFFICE_PASS="your-password"
|
||||||
|
// go run ./examples/basic/
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load credentials from environment
|
||||||
|
creds := onlyoffice.GetEnvironmentCredentials()
|
||||||
|
if creds.Url == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := onlyoffice.NewClient(creds)
|
||||||
|
|
||||||
|
// List all projects
|
||||||
|
projects, err := client.GetProjects()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to get projects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Found %d projects:\n", len(projects))
|
||||||
|
for _, p := range projects {
|
||||||
|
fmt.Printf(" - [%d] %s (tasks: %d)\n", *p.ID, *p.Title, safeInt(p.TaskCountTotal))
|
||||||
|
}
|
||||||
|
|
||||||
|
// List users
|
||||||
|
users, err := client.GetUsers()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to get users: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nFound %d users:\n", len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
fmt.Printf(" - %s (%s)\n", safeStr(u.DisplayName), safeStr(u.Email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a demo project (uncomment to test)
|
||||||
|
// project, err := client.CreateProject(onlyoffice.NewProjectRequest{
|
||||||
|
// Title: "Demo Project",
|
||||||
|
// Description: "Created via API example",
|
||||||
|
// })
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatalf("Failed to create project: %v", err)
|
||||||
|
// }
|
||||||
|
// fmt.Printf("\nCreated project: %s (ID: %d)\n", *project.Title, *project.ID)
|
||||||
|
|
||||||
|
// Create a task in first project (uncomment to test)
|
||||||
|
// if len(projects) > 0 {
|
||||||
|
// task, err := client.CreateProjectTask(onlyoffice.NewProjectTaskRequest{
|
||||||
|
// Title: "Demo Task",
|
||||||
|
// Description: "Created via API example",
|
||||||
|
// ProjectId: *projects[0].ID,
|
||||||
|
// StartDate: onlyoffice.Time(time.Now()),
|
||||||
|
// Deadline: onlyoffice.Time(time.Now().AddDate(0, 0, 7)),
|
||||||
|
// })
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatalf("Failed to create task: %v", err)
|
||||||
|
// }
|
||||||
|
// fmt.Printf("Created task: %s (ID: %d)\n", *task.Title, *task.ID)
|
||||||
|
// }
|
||||||
|
|
||||||
|
_ = time.Now // suppress unused import if examples are commented out
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeInt(i *int) int {
|
||||||
|
if i == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *i
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
module github.com/eslider/go-onlyoffice
|
||||||
|
|
||||||
|
go 1.22.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/go-querystring v1.2.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||||
|
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
+650
@@ -0,0 +1,650 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvironmentCredentials when using environment variables
|
||||||
|
func GetEnvironmentCredentials() Credentials {
|
||||||
|
return Credentials{
|
||||||
|
Url: os.Getenv("ONLYOFFICE_URL"),
|
||||||
|
User: os.Getenv("ONLYOFFICE_USER"),
|
||||||
|
Password: os.Getenv("ONLYOFFICE_PASS"),
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user