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:
@@ -9,12 +9,12 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
- `request.go` — `Request`, `Query`, `Time`, `Token`, `MetaResponse`, `Permissions`.
|
||||||
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
- `auth.go` — `Authenticate`, `AuthenticateContext`, `InvalidateToken`, `Auth`, token lifecycle.
|
||||||
- `http.go` — transport + DRY response decoders (`ResponseArray`/`ResponseObject`/`postFormObject`/`putFormObject`/`deleteObject`).
|
- `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.
|
- 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):
|
- **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).
|
- `main.go` — entry point (docstring lists the command tree).
|
||||||
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
|
- `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.
|
- 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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.5.0] - 2026-04-24
|
||||||
|
|
||||||
### Changed — CLI **BREAKING**
|
### Changed — CLI **BREAKING**
|
||||||
|
|||||||
@@ -559,13 +559,23 @@ oo opportunities stages
|
|||||||
oo cases list
|
oo cases list
|
||||||
oo crm-tasks categories
|
oo crm-tasks categories
|
||||||
oo applications sync --path ./applications/2026 --apply
|
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 |
|
| Subject | Verbs |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `calendar` | `list`, `events`, `add`, `delete` |
|
| `calendar` | `list`, `events`, `add`, `delete` |
|
||||||
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete` |
|
| `projects` | `list`, `get`, `milestones`, `create`, `update`, `delete`, **`files`** (`list`, `upload`, `download`, `rename`, `delete`) |
|
||||||
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add` |
|
| `tasks` | `list`, `get`, `create`, `update`, `delete`, `subtask add`, **`files`** (`list`, `upload`, `detach`) |
|
||||||
| `users` | `list`, `self` (alias: `oo whoami`) |
|
| `users` | `list`, `self` (alias: `oo whoami`) |
|
||||||
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
| `contacts` | `list`, `get`, `delete`, `info-add` |
|
||||||
| `persons` | `list` (filtered), `create`, `delete` |
|
| `persons` | `list` (filtered), `create`, `delete` |
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@
|
|||||||
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
// Command tree is subject-based (mirrors the library split and the `tea` CLI):
|
||||||
//
|
//
|
||||||
// oo calendar list | events | add | delete
|
// oo calendar list | events | add | delete
|
||||||
// oo projects list | get | milestones | create | update | delete
|
// oo projects list | get | milestones | create | update | delete | files (list|upload|download|rename|delete)
|
||||||
// oo tasks list | get | create | update | delete | subtask add
|
// oo tasks list | get | create | update | delete | subtask add | files (list|upload|detach)
|
||||||
// oo users list | self (alias: oo whoami)
|
// oo users list | self (alias: oo whoami)
|
||||||
// oo contacts list | get | delete | info-add
|
// oo contacts list | get | delete | info-add
|
||||||
// oo persons list | create | delete
|
// oo persons list | create | delete
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,383 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"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.
|
// UploadOpportunityFile uploads a single file to a CRM opportunity.
|
||||||
// Returns the decoded "response" object from OnlyOffice.
|
// Returns the decoded "response" object from OnlyOffice.
|
||||||
func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) {
|
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))
|
p := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID))
|
||||||
raw, err := c.uploadMultipart(ctx, path, "file", filePath)
|
raw, err := c.uploadMultipart(ctx, p, "file", filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return unmarshalResponseObject(raw)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -220,6 +220,49 @@ func (c *Client) deleteReq(ctx context.Context, path string) (json.RawMessage, e
|
|||||||
return raw, nil
|
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.
|
// 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) {
|
func (c *Client) uploadMultipart(ctx context.Context, path, fieldName, filePath string) (json.RawMessage, error) {
|
||||||
auth, err := c.authHeader()
|
auth, err := c.authHeader()
|
||||||
|
|||||||
Vendored
+25
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"status": 0,
|
||||||
|
"statusCode": 200,
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"id": 200,
|
||||||
|
"title": "attach.pdf",
|
||||||
|
"fileExst": ".pdf",
|
||||||
|
"contentLength": "1024",
|
||||||
|
"fileType": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user