From c576bfe2eed0b63cceb28b2e6ac753d71c3b01d0 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 14 Sep 2026 12:37:57 +0000 Subject: [PATCH] feat(files): Documents Dav ops + UpdateFile, fileops errors (#152) --- cmd/oo/dav.go | 291 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/oo/main.go | 1 + files.go | 28 +++++ files_webdav.go | 66 ++++++++++- 4 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 cmd/oo/dav.go diff --git a/cmd/oo/dav.go b/cmd/oo/dav.go new file mode 100644 index 0000000..4683444 --- /dev/null +++ b/cmd/oo/dav.go @@ -0,0 +1,291 @@ +package main + +import ( + "fmt" + "os" + + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(davCmd()) +} + +// davCmd exposes the Documents module through the same Dav calls that back +// oo-webdav (ListDavFolder / MoveDavItems / CopyDavItems / DownloadDavFile). +// MoveDavItems sends resolveType=Skip + holdResult=true, which the legacy +// fileops/move call without those params silently ignores (200 without move). +func davCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dav", + Short: "Documents module by folder/file id (oo-webdav proven path)", + } + cmd.AddCommand(davLsCmd()) + cmd.AddCommand(davMoveCmd()) + cmd.AddCommand(davCopyCmd()) + cmd.AddCommand(davMkdirCmd()) + cmd.AddCommand(davRenameFileCmd()) + cmd.AddCommand(davRenameFolderCmd()) + cmd.AddCommand(davDownloadCmd()) + cmd.AddCommand(davFileOpsCmd()) + return cmd +} + +func davLsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls FOLDER_ID", + Short: "List a Documents folder (@root for virtual sections)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + ctx := cmd.Context() + if args[0] == "@root" { + sections, err := c.ListDavSections(ctx) + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(sections)) + for _, s := range sections { + rows = append(rows, map[string]any{ + "id": s.ID, + "title": s.Title, + "filesCount": s.FilesCount, + "foldersCount": s.FoldersCount, + }) + } + printTable([]string{"id", "title", "filesCount", "foldersCount"}, rows) + return nil + } + l, err := c.ListDavFolder(ctx, args[0]) + if err != nil { + return err + } + if outputFormat == "json" { + folders := make([]map[string]any, 0, len(l.Folders)) + for _, f := range l.Folders { + folders = append(folders, map[string]any{ + "id": f.ID, + "title": f.Title, + "filesCount": f.FilesCount, + "foldersCount": f.FoldersCount, + }) + } + files := make([]map[string]any, 0, len(l.Files)) + for _, f := range l.Files { + files = append(files, map[string]any{ + "id": f.ID, + "title": f.Title, + "size": f.Size, + "updated": f.Updated, + }) + } + printObject(map[string]any{"folders": folders, "files": files}) + return nil + } + if len(l.Folders) > 0 { + frows := make([]map[string]any, 0, len(l.Folders)) + for _, f := range l.Folders { + frows = append(frows, map[string]any{ + "id": f.ID, + "title": f.Title, + "filesCount": f.FilesCount, + "foldersCount": f.FoldersCount, + }) + } + if outputFormat == "table" { + fmt.Println("folders:") + } + printTable([]string{"id", "title", "filesCount", "foldersCount"}, frows) + } + rows := make([]map[string]any, 0, len(l.Files)) + for _, f := range l.Files { + rows = append(rows, map[string]any{ + "id": f.ID, + "title": f.Title, + "size": f.Size, + "updated": f.Updated, + }) + } + if outputFormat == "table" { + fmt.Println("files:") + } + printTable([]string{"id", "title", "size", "updated"}, rows) + return nil + }, + } + return cmd +} + +func davMoveCmd() *cobra.Command { + var folderIDs []string + cmd := &cobra.Command{ + Use: "move DEST_FOLDER_ID FILE_ID [FILE_ID...]", + Short: "Move file(s) into a Documents folder (resolveType=Skip, holdResult)", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.MoveDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil { + return err + } + printObject(map[string]any{"moved_files": args[1:], "moved_folders": folderIDs, "dest": args[0]}) + return nil + }, + } + cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to move along with the files") + return cmd +} + +func davCopyCmd() *cobra.Command { + var folderIDs []string + cmd := &cobra.Command{ + Use: "copy DEST_FOLDER_ID FILE_ID [FILE_ID...]", + Short: "Copy file(s) into a Documents folder (conflictResolveType=Skip)", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.CopyDavItems(cmd.Context(), folderIDs, args[1:], args[0]); err != nil { + return err + } + printObject(map[string]any{"copied_files": args[1:], "copied_folders": folderIDs, "dest": args[0]}) + return nil + }, + } + cmd.Flags().StringSliceVar(&folderIDs, "folders", nil, "folder ids to copy along with the files") + return cmd +} + +func davMkdirCmd() *cobra.Command { + return &cobra.Command{ + Use: "mkdir PARENT_FOLDER_ID TITLE", + Short: "Create a subfolder in Documents", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + f, err := c.CreateDavFolder(cmd.Context(), args[0], args[1]) + if err != nil { + return err + } + printObject(map[string]any{"id": f.ID, "title": f.Title, "parent": args[0]}) + return nil + }, + } +} + +func davRenameFileCmd() *cobra.Command { + return &cobra.Command{ + Use: "rename-file FILE_ID NEW_TITLE", + Short: "Rename a Documents 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 + } + if err := c.RenameDavFile(cmd.Context(), args[0], args[1]); err != nil { + return err + } + printObject(map[string]any{"id": args[0], "title": args[1]}) + return nil + }, + } +} + +func davRenameFolderCmd() *cobra.Command { + return &cobra.Command{ + Use: "rename-folder FOLDER_ID NEW_TITLE", + Short: "Rename a Documents folder", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + if err := c.RenameDavFolder(cmd.Context(), args[0], args[1]); err != nil { + return err + } + printObject(map[string]any{"id": args[0], "title": args[1]}) + return nil + }, + } +} + +func davDownloadCmd() *cobra.Command { + var to string + cmd := &cobra.Command{ + Use: "download FILE_ID", + Short: "Download Documents file bytes (default path: ./)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + ctx := cmd.Context() + f, err := c.GetFile(ctx, 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.DownloadDavFile(ctx, args[0], out) + if err != nil { + _ = os.Remove(path) + return err + } + printObject(map[string]any{"path": path, "bytes": n}) + return nil + }, + } + cmd.Flags().StringVar(&to, "to", "", "output path (default: ./<server title>)") + return cmd +} + +func davFileOpsCmd() *cobra.Command { + return &cobra.Command{ + Use: "fileops", + Short: "List active file operations (move/copy status polling)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newOO(cmd) + if err != nil { + return err + } + ops, err := c.ListFileOps(cmd.Context()) + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(ops)) + for _, op := range ops { + rows = append(rows, map[string]any{ + "id": fmt.Sprint(op["id"]), + "operation": fmt.Sprint(op["operation"]), + "progress": fmt.Sprint(op["progress"]), + "finished": fmt.Sprint(op["finished"]), + "error": fmt.Sprint(op["error"]), + }) + } + printTable([]string{"id", "operation", "progress", "finished", "error"}, rows) + return nil + }, + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 63c2f83..7422a2d 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -16,6 +16,7 @@ // oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … // oo docs tools | convert | ocr | as-md | put-md +// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // // CRM association rules: docs/crm-associations.md // diff --git a/files.go b/files.go index 43a740e..4d0497b 100644 --- a/files.go +++ b/files.go @@ -321,11 +321,21 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) "folderIds": []int{}, "fileIds": fileIDs, "destFolderId": destFolderID, + "resolveType": "Skip", + "holdResult": true, } out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body) if err != nil { out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body) } + if err != nil { + return nil, err + } + if raw, merr := json.Marshal(out); merr == nil { + if ferr := fileopsError(raw); ferr != nil { + return nil, ferr + } + } return out, err } @@ -346,6 +356,24 @@ func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) return decodeResponseFileEntry(raw) } +// UpdateFile uploads a new version of an existing file (same id, name and +// folder). It does not delete and does not create a second file. +func (c *Client) UpdateFile(ctx context.Context, fileID, localPath string) (*FileEntry, error) { + if fileID == "" || localPath == "" { + return nil, fmt.Errorf("file id and local path are required") + } + uploadPath := fmt.Sprintf("/api/2.0/files/%s/update", url.PathEscape(fileID)) + raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath) + if err != nil { + uploadPath = fmt.Sprintf("/api/2.0/files/%s/update.json", url.PathEscape(fileID)) + raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath) + if err != nil { + return nil, err + } + } + return decodeResponseFileEntry(raw) +} + // FileFolderID returns the parent folder id string for a file entry, if known. func FileFolderID(f *FileEntry) string { if f == nil || f.FolderID == nil { diff --git a/files_webdav.go b/files_webdav.go index f4d7e32..af2c232 100644 --- a/files_webdav.go +++ b/files_webdav.go @@ -144,27 +144,85 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error { } // MoveDavItems moves the given folders and/or files into destFolderID. +// The fileops API answers 200 with per-operation error strings even when +// nothing moves (e.g. missing permission), so the response is parsed and the +// first operation error is returned instead of a silent nil. func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { - _, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ + raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ "folderIds": nums(folderIDs), "fileIds": nums(fileIDs), "destFolderId": num(destFolderID), "resolveType": "Skip", "holdResult": true, }) - return err + if err != nil { + return err + } + return fileopsError(raw) } // CopyDavItems copies the given folders and/or files into destFolderID. +// Per-operation errors are surfaced like in MoveDavItems. func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { - _, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ + raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ "folderIds": nums(folderIDs), "fileIds": nums(fileIDs), "destFolderId": num(destFolderID), "conflictResolveType": "Skip", "deleteAfter": true, }) - return err + if err != nil { + return err + } + return fileopsError(raw) +} + +// ListFileOps returns the currently active file operations +// (GET /api/2.0/files/fileops) for status polling. +func (c *Client) ListFileOps(ctx context.Context) ([]map[string]any, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/fileops") + 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 ops []map[string]any + if err := json.Unmarshal(resp, &ops); err != nil { + return nil, err + } + return ops, nil +} + +// fileopsError extracts per-operation "error" strings from a fileops/move or +// fileops/copy envelope. A 200 with error entries means nothing moved. +func fileopsError(raw json.RawMessage) error { + resp, err := responseField(raw, "response") + if err != nil { + return err + } + var ops []struct { + Error *string `json:"error"` + Finished *bool `json:"finished"` + Progress *int `json:"progress"` + } + if err := json.Unmarshal(resp, &ops); err != nil { + return nil // not an operations envelope — nothing to report + } + var errs []string + for _, op := range ops { + if op.Error != nil && *op.Error != "" { + errs = append(errs, *op.Error) + } + } + if len(errs) > 0 { + return fmt.Errorf("onlyoffice: fileops: %s", strings.Join(errs, "; ")) + } + return nil } // DeleteDavItems deletes the given folders and/or files.