From e03fd6220012c73b110f1e0b02a12971174eb290 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 24 Apr 2026 16:28:39 +0100 Subject: [PATCH] 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 --- AGENTS.md | 4 +- CHANGELOG.md | 35 +++ README.md | 14 +- cmd/oo/main.go | 4 +- cmd/oo/projects_files.go | 223 ++++++++++++++++ cmd/oo/tasks_files.go | 91 +++++++ files.go | 370 ++++++++++++++++++++++++++- files_integration_test.go | 175 +++++++++++++ files_test.go | 70 +++++ http.go | 43 ++++ testdata/project_files_response.json | 25 ++ testdata/task_files_response.json | 14 + 12 files changed, 1060 insertions(+), 8 deletions(-) create mode 100644 cmd/oo/projects_files.go create mode 100644 cmd/oo/tasks_files.go create mode 100644 files_integration_test.go create mode 100644 files_test.go create mode 100644 testdata/project_files_response.json create mode 100644 testdata/task_files_response.json diff --git a/AGENTS.md b/AGENTS.md index 3852431..c570473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1baa3ce..2cc2a63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` (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** diff --git a/README.md b/README.md index 18e2cd7..baefbdb 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 50ea7df..e186591 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -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 diff --git a/cmd/oo/projects_files.go b/cmd/oo/projects_files.go new file mode 100644 index 0000000..2acef18 --- /dev/null +++ b/cmd/oo/projects_files.go @@ -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: ./)", + 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() +} diff --git a/cmd/oo/tasks_files.go b/cmd/oo/tasks_files.go new file mode 100644 index 0000000..ec347e7 --- /dev/null +++ b/cmd/oo/tasks_files.go @@ -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 + }, + } +} diff --git a/files.go b/files.go index 2e5381a..794821c 100644 --- a/files.go +++ b/files.go @@ -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 +} diff --git a/files_integration_test.go b/files_integration_test.go new file mode 100644 index 0000000..2653c43 --- /dev/null +++ b/files_integration_test.go @@ -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) + } + } +} diff --git a/files_test.go b/files_test.go new file mode 100644 index 0000000..abc8b06 --- /dev/null +++ b/files_test.go @@ -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) + } +} diff --git a/http.go b/http.go index 044a42e..5e701e9 100644 --- a/http.go +++ b/http.go @@ -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() diff --git a/testdata/project_files_response.json b/testdata/project_files_response.json new file mode 100644 index 0000000..9759a40 --- /dev/null +++ b/testdata/project_files_response.json @@ -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" + } + ] + } +} diff --git a/testdata/task_files_response.json b/testdata/task_files_response.json new file mode 100644 index 0000000..09e58e5 --- /dev/null +++ b/testdata/task_files_response.json @@ -0,0 +1,14 @@ +{ + "count": 1, + "status": 0, + "statusCode": 200, + "response": [ + { + "id": 200, + "title": "attach.pdf", + "fileExst": ".pdf", + "contentLength": "1024", + "fileType": 2 + } + ] +}