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: ./)")
+ 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.