feat(files): project/task Documents API + oo projects|tasks files

Library (files.go):
- Types: FileEntry, FolderEntry, ProjectFilesResponse.
- GetProjectFiles, GetTaskFiles, GetFile, UploadProjectFile (POST .../upload),
  AttachFilesToTask (POST form files=), UploadTaskFile (upload+attach),
  DetachTaskFile (DELETE ?fileid=), RenameFile, DeleteFiles, DownloadFile.
- Helpers: FileEntryNumericID, FileEntryTitle, SafeLocalFileName, projectIDFromTaskMap.

http.go: putJSON for JSON PUT bodies.

CLI: oo projects files {list,upload,download,rename,delete}, oo tasks files
{list,upload,detach}; projects_files.go + tasks_files.go.

Tests: files_integration_test.go (live), files_test.go + testdata (decode).

Docs: CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree.
Made-with: Cursor
This commit is contained in:
2026-04-24 16:28:39 +01:00
parent 1e6d22f38d
commit e03fd62200
12 changed files with 1060 additions and 8 deletions
+2 -2
View File
@@ -9,12 +9,12 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go` — typed / untyped domain methods.
- `projects.go`, `tasks.go`, `users.go`, `calendar.go`, `crm.go`, `files.go` — typed / untyped domain methods. **`files.go`** — CRM opportunity upload plus **project/task Documents** (`GetProjectFiles`, `UploadProjectFile`, `GetTaskFiles`, `AttachFilesToTask`, `UploadTaskFile`, `DetachTaskFile`, `GetFile`, `RenameFile`, `DeleteFiles`, `DownloadFile`).
- Pure stdlib + `google/go-querystring`; no UI, no dotenv.
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
- `main.go` — entry point (docstring lists the command tree).
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
- `calendar.go`, `projects.go`, `tasks.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject, each registers its subject `cobra.Command` in `init()` and attaches verb subcommands (`list`/`get`/`create`/`update`/`delete`/…).
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
+35
View File
@@ -6,6 +6,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.6.0] - 2026-04-24
### Added — library
- **Project & task Documents API** in [`files.go`](files.go):
- `FileEntry`, `FolderEntry`, `ProjectFilesResponse` types.
- `GetProjectFiles`, `GetTaskFiles`, `GetFile`.
- `UploadProjectFile` — `POST /api/2.0/files/{folderId}/upload` into the
project's `projectFolder` (resolved via `GetProjectByID` or first folder
from `GetProjectFiles`).
- `AttachFilesToTask` — `POST .../project/task/{id}/files` with form
`files=<id>` (OnlyOffice expects **existing** file ids, not multipart).
- `UploadTaskFile` — uploads via `UploadProjectFile` using the task's
`projectOwner.id`, then attaches.
- `DetachTaskFile` — `DELETE .../files?fileid=`.
- `RenameFile` — `PUT /api/2.0/files/file/{id}.json` with JSON body.
- `DeleteFiles` — `PUT /api/2.0/files/fileops/delete.json` with `fileIds`.
- `DownloadFile` — `GetFile` then `GET` on `viewUrl` with `Authorization`.
- Helpers: `FileEntryNumericID`, `FileEntryTitle`, `SafeLocalFileName`.
- **`putJSON`** on `*Client` in [`http.go`](http.go) for JSON PUT bodies.
### Added — CLI
- `oo projects files list|upload|download|rename|delete` — see
[`cmd/oo/projects_files.go`](cmd/oo/projects_files.go); `list` supports
`--folders`.
- `oo tasks files list|upload|detach` — see [`cmd/oo/tasks_files.go`](cmd/oo/tasks_files.go).
### Added — tests
- [`files_integration_test.go`](files_integration_test.go) — live roundtrip
against OnlyOffice (same credential rules as `client_test.go`).
- [`files_test.go`](files_test.go) + `testdata/*.json` — envelope decode unit
tests (no network).
## [0.5.0] - 2026-04-24
### Changed — CLI **BREAKING**
+12 -2
View File
@@ -559,13 +559,23 @@ oo opportunities stages
oo cases list
oo crm-tasks categories
oo applications sync --path ./applications/2026 --apply
# Project Documents (files module)
oo projects files list 33
oo projects files upload 33 ./notes.md
oo projects files download 12345 --to ./copy.md
oo projects files rename 12345 notes-v2.md
oo projects files delete 12345
oo tasks files list 208
oo tasks files upload 208 ./cv.pdf
oo tasks files detach 208 12345
```
| Subject | Verbs |
|---|---|
| `calendar` | `list`, `events`, `add`, `delete` |
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete` |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add` |
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
| `users` | `list`, `self` (alias: `oo whoami`) |
| `contacts` | `list`, `get`, `delete`, `info-add` |
| `persons` | `list` (filtered), `create`, `delete` |
+2 -2
View File
@@ -3,8 +3,8 @@
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
//
// oo calendar list | events | add | delete
// oo projects list | get | milestones | create | update | delete
// oo tasks list | get | create | update | delete | subtask add
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
// oo users list | self (alias: oo whoami)
// oo contacts list | get | delete | info-add
// oo persons list | create | delete
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"fmt"
"os"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/spf13/cobra"
)
func init() {
projectsCmd.AddCommand(projectFilesCmd())
}
func projectFilesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "files",
Short: "Project Documents folder: list, upload, download, rename, delete",
}
cmd.AddCommand(prjFilesListCmd())
cmd.AddCommand(prjFilesUploadCmd())
cmd.AddCommand(prjFilesDownloadCmd())
cmd.AddCommand(prjFilesRenameCmd())
cmd.AddCommand(prjFilesDeleteCmd())
return cmd
}
func prjFilesListCmd() *cobra.Command {
var showFolders bool
cmd := &cobra.Command{
Use: "list PROJECT_ID",
Short: "List files (and optionally folders) attached to the project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pf, err := c.GetProjectFiles(cmd.Context(), args[0])
if err != nil {
return err
}
if showFolders && len(pf.Folders) > 0 {
frows := make([]map[string]any, 0, len(pf.Folders))
for _, f := range pf.Folders {
if f == nil {
continue
}
frows = append(frows, map[string]any{
"id": folderIDStr(f),
"title": derefString(f.Title),
"filesCount": derefInt(f.FilesCount),
"foldersCount": derefInt(f.FoldersCount),
})
}
if outputFormat == "table" {
fmt.Println("folders:")
}
printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows)
}
rows := fileEntryRows(pf.Files)
if outputFormat == "table" {
fmt.Println("files:")
}
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, rows)
return nil
},
}
cmd.Flags().BoolVar(&showFolders, "folders", false, "also print project subfolders")
return cmd
}
func prjFilesUploadCmd() *cobra.Command {
return &cobra.Command{
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload file(s) into the project's Documents folder",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid := args[0]
for _, p := range args[1:] {
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
}
return nil
},
}
}
func prjFilesDownloadCmd() *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "download FILE_ID",
Short: "Download file bytes via viewUrl (default path: ./<title>)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
f, err := c.GetFile(cmd.Context(), args[0])
if err != nil {
return err
}
path := to
if path == "" {
path = onlyoffice.SafeLocalFileName(onlyoffice.FileEntryTitle(f))
}
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
n, err := c.DownloadFile(cmd.Context(), args[0], out)
if err != nil {
_ = os.Remove(path)
return err
}
if outputFormat == "json" {
printObject(map[string]any{"path": path, "bytes": n})
return nil
}
fmt.Printf("downloaded: %s (%d bytes)\n", path, n)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)")
return cmd
}
func prjFilesRenameCmd() *cobra.Command {
return &cobra.Command{
Use: "rename FILE_ID NEW_TITLE",
Short: "Rename a file (include extension in NEW_TITLE)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
entry, err := c.RenameFile(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
return nil
},
}
}
func prjFilesDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete FILE_ID [FILE_ID...]",
Aliases: []string{"rm"},
Short: "Permanently delete file(s) from Documents",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
ids := make([]int, 0, len(args))
for _, s := range args {
id, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("file id %q: %w", s, err)
}
ids = append(ids, id)
}
if err := c.DeleteFiles(cmd.Context(), ids); err != nil {
return err
}
printObject(map[string]any{"deleted": ids})
return nil
},
}
}
func fileEntryRows(files []*onlyoffice.FileEntry) []map[string]any {
rows := make([]map[string]any, 0, len(files))
for _, f := range files {
if f == nil {
continue
}
rows = append(rows, fileEntryToMap(f))
}
return rows
}
func fileEntryToMap(f *onlyoffice.FileEntry) map[string]any {
m := map[string]any{
"id": fileIDStr(f),
"title": onlyoffice.FileEntryTitle(f),
"fileExst": derefString(f.FileExst),
"contentLength": derefString(f.ContentLength),
}
if f.Updated != nil {
m["updated"] = f.Updated.Format(time.RFC3339)
}
return m
}
func fileIDStr(f *onlyoffice.FileEntry) string {
if f == nil || f.ID == nil {
return ""
}
return f.ID.String()
}
func folderIDStr(f *onlyoffice.FolderEntry) string {
if f == nil || f.ID == nil {
return ""
}
return f.ID.String()
}
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
func init() {
tasksCmd.AddCommand(taskFilesCmd())
}
func taskFilesCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "files",
Short: "Task attachments: list, upload (project folder + attach), detach",
}
cmd.AddCommand(taskFilesListCmd())
cmd.AddCommand(taskFilesUploadCmd())
cmd.AddCommand(taskFilesDetachCmd())
return cmd
}
func taskFilesListCmd() *cobra.Command {
return &cobra.Command{
Use: "list TASK_ID",
Short: "List files attached to a task",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
list, err := c.GetTaskFiles(cmd.Context(), args[0])
if err != nil {
return err
}
printTable([]string{"id", "title", "fileExst", "contentLength", "updated"}, fileEntryRows(list))
return nil
},
}
}
func taskFilesUploadCmd() *cobra.Command {
return &cobra.Command{
Use: "upload TASK_ID LOCAL_PATH [LOCAL_PATH...]",
Short: "Upload into the task's project folder and attach each file to the task",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
tid := args[0]
for _, p := range args[1:] {
entry, err := c.UploadTaskFile(cmd.Context(), tid, p)
if err != nil {
return err
}
printObject(fileEntryToMap(entry))
}
return nil
},
}
}
func taskFilesDetachCmd() *cobra.Command {
return &cobra.Command{
Use: "detach TASK_ID FILE_ID [FILE_ID...]",
Short: "Detach file(s) from the task (files remain in Documents)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
tid := args[0]
for _, fid := range args[1:] {
if _, err := strconv.Atoi(fid); err != nil {
return fmt.Errorf("file id %q: %w", fid, err)
}
if err := c.DetachTaskFile(cmd.Context(), tid, fid); err != nil {
return err
}
printObject(map[string]any{"taskId": tid, "detachedFileId": fid})
}
return nil
},
}
}
+368 -2
View File
@@ -2,17 +2,383 @@ package onlyoffice
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
)
// FileEntry is a file row from the OnlyOffice Files module or project/task
// file listings (field names follow the Workspace API JSON).
type FileEntry struct {
ID *json.Number `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
FileExst *string `json:"fileExst,omitempty"`
ContentLength *string `json:"contentLength,omitempty"`
FileType *int `json:"fileType,omitempty"`
ViewURL *string `json:"viewUrl,omitempty"`
WebURL *string `json:"webUrl,omitempty"`
FolderID *json.Number `json:"folderId,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
CreatedBy *User `json:"createdBy,omitempty"`
}
// FolderEntry is a folder row from project files listing.
type FolderEntry struct {
ID *json.Number `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
FilesCount *int `json:"filesCount,omitempty"`
FoldersCount *int `json:"foldersCount,omitempty"`
}
// ProjectFilesResponse is the "response" object from GET
// /api/2.0/project/{id}/files — files and folders attached to the project.
type ProjectFilesResponse struct {
Folders []*FolderEntry `json:"folders"`
Files []*FileEntry `json:"files"`
}
// 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)
p := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID))
raw, err := c.uploadMultipart(ctx, p, "file", filePath)
if err != nil {
return nil, err
}
return unmarshalResponseObject(raw)
}
// GetProjectFiles returns files and folders linked to the project.
func (c *Client) GetProjectFiles(ctx context.Context, projectID string) (*ProjectFilesResponse, error) {
if projectID == "" {
projectID = c.defaults.ProjectID
}
if projectID == "" {
return nil, fmt.Errorf("project id is required")
}
p := fmt.Sprintf("/api/2.0/project/%s/files.json", url.PathEscape(projectID))
raw, err := c.getJSON(ctx, p)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if len(resp) == 0 || string(resp) == "null" {
return &ProjectFilesResponse{}, nil
}
var out ProjectFilesResponse
if err := json.Unmarshal(resp, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetTaskFiles returns files attached to a project task.
func (c *Client) GetTaskFiles(ctx context.Context, taskID string) ([]*FileEntry, error) {
if taskID == "" {
return nil, fmt.Errorf("task id is required")
}
p := fmt.Sprintf("/api/2.0/project/task/%s/files.json", url.PathEscape(taskID))
raw, err := c.getJSON(ctx, p)
if err != nil {
return nil, err
}
resp, err := responseField(raw, "response")
if err != nil {
return nil, err
}
if len(resp) == 0 || string(resp) == "null" {
return nil, nil
}
var list []*FileEntry
if err := json.Unmarshal(resp, &list); err != nil {
return nil, err
}
return list, nil
}
// UploadTaskFile uploads a file into the task's project Documents folder, then
// attaches the new file id to the task. OnlyOffice POST .../task/{id}/files
// expects existing file IDs, not a multipart body.
func (c *Client) UploadTaskFile(ctx context.Context, taskID, localPath string) (*FileEntry, error) {
if taskID == "" {
return nil, fmt.Errorf("task id is required")
}
task, err := c.GetTaskByID(ctx, taskID)
if err != nil {
return nil, err
}
pid := projectIDFromTaskMap(task)
if pid == "" {
return nil, fmt.Errorf("task %s: cannot resolve project id for upload", taskID)
}
entry, err := c.UploadProjectFile(ctx, pid, localPath)
if err != nil {
return nil, err
}
nid := int(FileEntryNumericID(entry))
if nid == 0 {
return nil, fmt.Errorf("upload returned no file id")
}
if err := c.AttachFilesToTask(ctx, taskID, nid); err != nil {
return nil, err
}
return entry, nil
}
// AttachFilesToTask links existing Documents-module files to a task.
func (c *Client) AttachFilesToTask(ctx context.Context, taskID string, fileIDs ...int) error {
if taskID == "" || len(fileIDs) == 0 {
return fmt.Errorf("task id and at least one file id are required")
}
v := url.Values{}
for _, id := range fileIDs {
v.Add("files", strconv.Itoa(id))
}
p := fmt.Sprintf("/api/2.0/project/task/%s/files.json", url.PathEscape(taskID))
if _, err := c.postForm(ctx, p, v); err != nil {
p2 := fmt.Sprintf("/api/2.0/project/task/%s/files", url.PathEscape(taskID))
if _, err2 := c.postForm(ctx, p2, v); err2 != nil {
return fmt.Errorf("attach files to task: %w (retry: %v)", err, err2)
}
}
return nil
}
func projectIDFromTaskMap(m map[string]any) string {
if m == nil {
return ""
}
if po, ok := m["projectOwner"].(map[string]any); ok {
if id, ok := po["id"]; ok {
switch x := id.(type) {
case float64:
return strconv.FormatInt(int64(x), 10)
case int:
return strconv.Itoa(x)
case string:
return x
}
}
}
return ""
}
// DetachTaskFile removes a file attachment from the task (file remains in Documents).
func (c *Client) DetachTaskFile(ctx context.Context, taskID, fileID string) error {
if taskID == "" || fileID == "" {
return fmt.Errorf("task id and file id are required")
}
q := url.Values{}
q.Set("fileid", fileID)
p := fmt.Sprintf("/api/2.0/project/task/%s/files.json?%s", url.PathEscape(taskID), q.Encode())
if _, err := c.deleteReq(ctx, p); err != nil {
p2 := fmt.Sprintf("/api/2.0/project/task/%s/files?%s", url.PathEscape(taskID), q.Encode())
if _, err2 := c.deleteReq(ctx, p2); err2 != nil {
return fmt.Errorf("detach task file: %w (retry: %v)", err, err2)
}
}
return nil
}
// projectFolderID resolves the Documents folder id for project file uploads.
func (c *Client) projectFolderID(ctx context.Context, projectID string) (string, error) {
m, err := c.GetProjectByID(ctx, projectID)
if err != nil {
return "", err
}
if v, ok := m["projectFolder"]; ok && v != nil {
switch x := v.(type) {
case float64:
return strconv.FormatInt(int64(x), 10), nil
case json.Number:
return x.String(), nil
case string:
if x != "" {
return x, nil
}
}
}
// Fallback: first folder from project files listing.
pf, err := c.GetProjectFiles(ctx, projectID)
if err != nil {
return "", err
}
if len(pf.Folders) > 0 && pf.Folders[0].ID != nil {
return pf.Folders[0].ID.String(), nil
}
return "", fmt.Errorf("project %s has no projectFolder and no folders in files listing", projectID)
}
// UploadProjectFile uploads a file into the project's Documents folder.
func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath string) (*FileEntry, error) {
folderID, err := c.projectFolderID(ctx, projectID)
if err != nil {
return nil, err
}
// Workspace DocumentsApi.UploadFile: POST .../{folderId}/upload (multipart or raw stream).
uploadPath := fmt.Sprintf("/api/2.0/files/%s/upload.json", url.PathEscape(folderID))
raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath)
if err != nil {
uploadPath = fmt.Sprintf("/api/2.0/files/%s/upload", url.PathEscape(folderID))
raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath)
if err != nil {
return nil, err
}
}
return decodeResponseFileEntry(raw)
}
// GetFile returns file metadata including viewUrl for download.
func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) {
if fileID == "" {
return nil, fmt.Errorf("file id is required")
}
p := fmt.Sprintf("/api/2.0/files/file/%s.json", url.PathEscape(fileID))
raw, err := c.getJSON(ctx, p)
if err != nil {
return nil, err
}
return decodeResponseFileEntry(raw)
}
// RenameFile sets a new title (including extension) for the file.
func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) {
if fileID == "" || newTitle == "" {
return nil, fmt.Errorf("file id and new title are required")
}
p := fmt.Sprintf("/api/2.0/files/file/%s.json", url.PathEscape(fileID))
raw, err := c.putJSON(ctx, p, map[string]string{"title": newTitle})
if err != nil {
return nil, err
}
return decodeResponseFileEntry(raw)
}
type deleteFilesBody struct {
FileIDs []int `json:"fileIds"`
FolderIDs []int `json:"folderIds"`
}
// DeleteFiles permanently deletes files by numeric id (Documents module).
func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
if len(fileIDs) == 0 {
return fmt.Errorf("no file ids to delete")
}
body := deleteFilesBody{FileIDs: fileIDs, FolderIDs: nil}
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/delete.json", body)
if err != nil {
_, err = c.putJSON(ctx, "/api/2.0/files/fileops/delete", body)
}
return err
}
// DownloadFile streams file bytes from the file's viewUrl using the same auth
// as API calls. Writes into dst.
func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) {
f, err := c.GetFile(ctx, fileID)
if err != nil {
return 0, err
}
if f.ViewURL == nil || *f.ViewURL == "" {
return 0, fmt.Errorf("file %s has no viewUrl", fileID)
}
downloadURL := c.resolveAPIURL(*f.ViewURL)
auth, err := c.authHeader()
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", auth)
resp, err := c.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return 0, fmt.Errorf("GET viewUrl: %d %s", resp.StatusCode, truncate(string(b), 400))
}
n, err := io.Copy(dst, resp.Body)
return n, err
}
func (c *Client) resolveAPIURL(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ref
}
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
return ref
}
base := c.baseURL()
if strings.HasPrefix(ref, "/") {
u, err := url.Parse(base)
if err != nil {
return base + ref
}
u.Path = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/") + ref
}
return base + "/" + strings.TrimPrefix(ref, "/")
}
func decodeResponseFileEntry(raw json.RawMessage) (*FileEntry, error) {
var env struct {
Response *FileEntry `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
if env.Response == nil {
return nil, fmt.Errorf("empty file response")
}
return env.Response, nil
}
// FileEntryNumericID returns the file id as int64, or 0 if missing/invalid.
func FileEntryNumericID(f *FileEntry) int64 {
if f == nil || f.ID == nil {
return 0
}
n, err := f.ID.Int64()
if err != nil {
return 0
}
return n
}
// FileEntryTitle returns the title or empty string.
func FileEntryTitle(f *FileEntry) string {
if f == nil || f.Title == nil {
return ""
}
return *f.Title
}
// SafeLocalFileName sanitizes a server title for use as a local filename.
func SafeLocalFileName(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "download"
}
base := path.Base(title)
base = strings.ReplaceAll(base, "\x00", "")
if base == "." || base == "/" {
return "download"
}
return base
}
+175
View File
@@ -0,0 +1,175 @@
//go:build integration
package onlyoffice
import (
"bytes"
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
// TestIntegrationProjectFilesFlow creates a throwaway project, uploads a file
// into its Documents folder, lists, downloads, renames, deletes, then removes
// the project. Destructive — only run against instances you own.
func TestIntegrationProjectFilesFlow(t *testing.T) {
c := liveClient(t)
t.Cleanup(func() { cleanupTestProjects(t, c) })
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + "files-" + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "go-onlyoffice project files integration",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
}
pid := strconv.Itoa(*project.ID)
ctx := context.Background()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "hello.txt")
content := []byte("integration project file " + suffix + "\n")
if err := os.WriteFile(localPath, content, 0o600); err != nil {
t.Fatal(err)
}
entry, err := c.UploadProjectFile(ctx, pid, localPath)
if err != nil {
t.Fatalf("UploadProjectFile: %v", err)
}
if entry == nil || entry.ID == nil {
t.Fatalf("upload returned no file: %+v", entry)
}
fileID := entry.ID.String()
pf, err := c.GetProjectFiles(ctx, pid)
if err != nil {
t.Fatalf("GetProjectFiles: %v", err)
}
found := false
for _, f := range pf.Files {
if f != nil && f.ID != nil && f.ID.String() == fileID {
found = true
break
}
}
if !found {
t.Logf("uploaded file id=%s not in project files list (may still be ok); folders=%d files=%d",
fileID, len(pf.Folders), len(pf.Files))
}
var buf bytes.Buffer
n, err := c.DownloadFile(ctx, fileID, &buf)
if err != nil {
t.Fatalf("DownloadFile: %v", err)
}
if n != int64(len(content)) || !bytes.Equal(buf.Bytes(), content) {
t.Fatalf("download mismatch: got %d bytes %q want %d bytes", n, buf.String(), len(content))
}
newTitle := "renamed-" + suffix + ".txt"
renamed, err := c.RenameFile(ctx, fileID, newTitle)
if err != nil {
t.Fatalf("RenameFile: %v", err)
}
if renamed == nil || renamed.Title == nil || *renamed.Title != newTitle {
t.Fatalf("rename result: %+v", renamed)
}
nid := int(FileEntryNumericID(entry))
if nid == 0 {
t.Fatal("file id 0 for delete")
}
if err := c.DeleteFiles(ctx, []int{nid}); err != nil {
t.Fatalf("DeleteFiles: %v", err)
}
}
func TestIntegrationTaskFilesFlow(t *testing.T) {
c := liveClient(t)
t.Cleanup(func() { cleanupTestProjects(t, c) })
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + "taskfiles-" + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "go-onlyoffice task files integration",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
}
start := Time(time.Now().AddDate(0, 0, -1))
deadline := Time(time.Now().AddDate(0, 0, 1))
task, err := c.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID,
Title: "task for file attach " + suffix,
Description: "integration",
StartDate: start,
Deadline: deadline,
Priority: int(TaskPriorityNormal),
})
if err != nil {
t.Fatalf("CreateProjectTask: %v", err)
}
if task.ID == nil {
t.Fatal("task without id")
}
tid := strconv.Itoa(*task.ID)
ctx := context.Background()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "attach.txt")
content := []byte("task attachment " + suffix + "\n")
if err := os.WriteFile(localPath, content, 0o600); err != nil {
t.Fatal(err)
}
up, err := c.UploadTaskFile(ctx, tid, localPath)
if err != nil {
t.Fatalf("UploadTaskFile: %v", err)
}
if up == nil || up.ID == nil {
t.Fatalf("upload: %+v", up)
}
fileID := up.ID.String()
list, err := c.GetTaskFiles(ctx, tid)
if err != nil {
t.Fatalf("GetTaskFiles: %v", err)
}
found := false
for _, f := range list {
if f != nil && f.ID != nil && f.ID.String() == fileID {
found = true
break
}
}
if !found {
t.Fatalf("uploaded file not in GetTaskFiles: %#v", list)
}
if err := c.DetachTaskFile(ctx, tid, fileID); err != nil {
t.Fatalf("DetachTaskFile: %v", err)
}
list2, err := c.GetTaskFiles(ctx, tid)
if err != nil {
t.Fatalf("GetTaskFiles after detach: %v", err)
}
for _, f := range list2 {
if f != nil && f.ID != nil && f.ID.String() == fileID {
t.Fatalf("file still attached after detach: %v", fileID)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
package onlyoffice
import (
"encoding/json"
"os"
"testing"
)
func TestDecodeProjectFilesEnvelope(t *testing.T) {
raw, err := os.ReadFile("testdata/project_files_response.json")
if err != nil {
t.Fatal(err)
}
resp, err := responseField(json.RawMessage(raw), "response")
if err != nil {
t.Fatal(err)
}
var pf ProjectFilesResponse
if err := json.Unmarshal(resp, &pf); err != nil {
t.Fatal(err)
}
if len(pf.Folders) != 1 || pf.Folders[0].Title == nil || *pf.Folders[0].Title != "Subfolder" {
t.Fatalf("folders: %+v", pf.Folders)
}
if len(pf.Files) != 1 || pf.Files[0].Title == nil || *pf.Files[0].Title != "readme.txt" {
t.Fatalf("files: %+v", pf.Files)
}
if pf.Files[0].ID == nil || pf.Files[0].ID.String() != "100" {
t.Fatalf("file id: %v", pf.Files[0].ID)
}
}
func TestDecodeTaskFilesEnvelope(t *testing.T) {
raw, err := os.ReadFile("testdata/task_files_response.json")
if err != nil {
t.Fatal(err)
}
resp, err := responseField(json.RawMessage(raw), "response")
if err != nil {
t.Fatal(err)
}
var list []*FileEntry
if err := json.Unmarshal(resp, &list); err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Title == nil || *list[0].Title != "attach.pdf" {
t.Fatalf("list: %+v", list)
}
}
func TestProjectIDFromTaskMap(t *testing.T) {
m := map[string]any{
"projectOwner": map[string]any{"id": float64(33)},
}
if got := projectIDFromTaskMap(m); got != "33" {
t.Fatalf("got %q", got)
}
if projectIDFromTaskMap(nil) != "" {
t.Fatal("expected empty")
}
}
func TestSafeLocalFileName(t *testing.T) {
if got := SafeLocalFileName(" foo/bar.txt "); got != "bar.txt" {
t.Fatalf("got %q", got)
}
if got := SafeLocalFileName(""); got != "download" {
t.Fatalf("got %q", got)
}
}
+43
View File
@@ -220,6 +220,49 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e
return raw, nil
}
// putJSON issues an authenticated PUT with application/json body.
func (c *Client) putJSON(ctx context.Context, path string, body any) (json.RawMessage, error) {
auth, err := c.authHeader()
if err != nil {
return nil, err
}
var rdr io.Reader
switch b := body.(type) {
case nil:
rdr = strings.NewReader("{}")
case []byte:
rdr = bytes.NewReader(b)
case string:
rdr = strings.NewReader(b)
default:
buf, err := json.Marshal(b)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.baseURL()+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", "application/json")
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("PUT JSON %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400))
}
return raw, 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()
+25
View File
@@ -0,0 +1,25 @@
{
"count": 1,
"status": 0,
"statusCode": 200,
"response": {
"folders": [
{
"id": 10,
"title": "Subfolder",
"filesCount": 0,
"foldersCount": 0
}
],
"files": [
{
"id": 100,
"title": "readme.txt",
"fileExst": ".txt",
"contentLength": "12",
"fileType": 2,
"viewUrl": "/Products/Files/DocEditor.aspx?fileid=100"
}
]
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"count": 1,
"status": 0,
"statusCode": 200,
"response": [
{
"id": 200,
"title": "attach.pdf",
"fileExst": ".pdf",
"contentLength": "1024",
"fileType": 2
}
]
}