Compare commits
14
Commits
35f0cb8d20
...
v0.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c576bfe2ee | ||
|
|
7f56332285 | ||
|
|
a5807b9c31 | ||
|
|
d650a16a36 | ||
|
|
3da11586f9 | ||
|
|
e9c969a89c | ||
|
|
4d91726179 | ||
|
|
4d8af7a2fe | ||
|
|
34745e349e | ||
|
|
dfea57a57b | ||
|
|
8595c17f25 | ||
|
|
61da2fb88b | ||
|
|
24ca144b22 | ||
|
|
93828ee19d |
@@ -0,0 +1,6 @@
|
|||||||
|
github: eSlider
|
||||||
|
ko_fi: eslider
|
||||||
|
liberapay: eslider
|
||||||
|
patreon: eslider
|
||||||
|
custom:
|
||||||
|
- https://polar.sh/eslider
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.16.0"
|
".": "0.17.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
|
|||||||
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
|
- Prefer `ResponseObject` / `postFormObject` / `putFormObject` / `deleteObject` over hand-rolled `json.Unmarshal(responseField(...))` blocks — they exist for DRY, use them.
|
||||||
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
|
- Domain split is by file, **not** by subpackage. Don't introduce `internal/` or `pkg/*` subpackages inside the library — it flattens the `*Client` call surface for a reason.
|
||||||
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
|
- CLI commands follow **subject → verb** structure (`oo <subject> <verb>`), never `oo <verb>-<subject>`. Add new commands to the existing subject file if one fits; create a new `cmd/oo/<subject>.go` for a genuinely new domain.
|
||||||
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`).
|
- **Documents for agents:** prefer Markdown in git; OnlyOffice UI is weak for `.md`/`.txt`. Use `oo docs put-md` (md→docx) and `oo docs put-txt` (txt→docx, preserves line breaks). All upload paths default to **upsert** by `stem|ext` (`--replace`, default true); `--no-replace` fails on conflict; `--allow-duplicate` opts into raw OO append. `oo projects files dedupe PROJECT_ID` reports/removes duplicate stem|ext copies (`--apply`, `--cross`; includes project root folder).
|
||||||
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
|
- Every table output goes through `printTable(headers, rows)`; every single-object through `printObject(v)`. Do not `fmt.Println` rows ad-hoc or the `--output json` flag breaks for that command.
|
||||||
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
|
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
|
||||||
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## [0.17.0](https://github.com/eSlider/go-onlyoffice/compare/v0.16.0...v0.17.0) (2026-08-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **mailsync:** FetchMailFolder — integration-layer walk for ETL consumers ([35f0cb8](https://github.com/eSlider/go-onlyoffice/commit/35f0cb8d20076244141065c07e203e633bc3612a))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **files:** upsert uploads by default and dedupe project root ([24ca144](https://github.com/eSlider/go-onlyoffice/commit/24ca144b22abc5d056a5fd1ed9a1887f26a79d15))
|
||||||
|
|
||||||
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
|
## [0.16.0](https://github.com/eSlider/go-onlyoffice/compare/v0.15.0...v0.16.0) (2026-08-30)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+291
@@ -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: ./<title>)",
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-58
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -423,44 +424,28 @@ func docsPutMDCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
var ent *onlyoffice.FileEntry
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||||
var deleted []int
|
|
||||||
if folderID != "" {
|
|
||||||
if replace {
|
|
||||||
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
|
|
||||||
} else {
|
|
||||||
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"folder_id": folderID,
|
|
||||||
"md": mdPath,
|
"md": mdPath,
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
obj["folder_id"] = folderID
|
||||||
|
}
|
||||||
if len(deleted) > 0 {
|
if len(deleted) > 0 {
|
||||||
obj["replaced_file_ids"] = deleted
|
obj["replaced_file_ids"] = deleted
|
||||||
}
|
}
|
||||||
printObject(obj)
|
printObject(obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
printObject(map[string]any{
|
|
||||||
"project_id": pid,
|
|
||||||
"md": mdPath,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
||||||
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
||||||
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-md upsert)")
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,44 +488,28 @@ func docsPutTxtCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
var ent *onlyoffice.FileEntry
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, docxPath, folderID, replace)
|
||||||
var deleted []int
|
|
||||||
if folderID != "" {
|
|
||||||
if replace {
|
|
||||||
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, docxPath)
|
|
||||||
} else {
|
|
||||||
ent, err = c.UploadToFolder(ctx, folderID, docxPath)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"project_id": pid,
|
"project_id": pid,
|
||||||
"txt": txtPath,
|
"txt": txtPath,
|
||||||
"folder_id": folderID,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
"uploaded": fileEntryToMap(ent),
|
||||||
}
|
}
|
||||||
|
if folderID != "" {
|
||||||
|
obj["folder_id"] = folderID
|
||||||
|
}
|
||||||
if len(deleted) > 0 {
|
if len(deleted) > 0 {
|
||||||
obj["replaced_file_ids"] = deleted
|
obj["replaced_file_ids"] = deleted
|
||||||
}
|
}
|
||||||
printObject(obj)
|
printObject(obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
ent, err = c.UploadProjectFile(ctx, pid, docxPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
printObject(map[string]any{
|
|
||||||
"project_id": pid,
|
|
||||||
"txt": txtPath,
|
|
||||||
"uploaded": fileEntryToMap(ent),
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
cmd.Flags().StringVar(&folderID, "folder", "", "Documents folder id (default: project root)")
|
||||||
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
cmd.Flags().StringVar(&keepLocalDOCX, "keep-docx", "", "also write the generated DOCX to this local path")
|
||||||
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files in folder before upload (put-txt upsert)")
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,21 +587,7 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := cmd.Context()
|
ctx := cmd.Context()
|
||||||
var ent *onlyoffice.FileEntry
|
ent, deleted, err := uploadProjectDoc(ctx, c, pid, xlsxPath, folderID, replace)
|
||||||
var deleted []int
|
|
||||||
if folderID != "" {
|
|
||||||
if replace {
|
|
||||||
ent, deleted, err = c.UploadToFolderReplacing(ctx, folderID, xlsxPath)
|
|
||||||
} else {
|
|
||||||
ent, err = c.UploadToFolder(ctx, folderID, xlsxPath)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if replace {
|
|
||||||
ent, deleted, err = c.UploadProjectFileReplacing(ctx, pid, xlsxPath)
|
|
||||||
} else {
|
|
||||||
ent, err = c.UploadProjectFile(ctx, pid, xlsxPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -655,6 +610,25 @@ formulas (SUM/AVG, cross-sheet refs, named inputs) via --template, or upload an
|
|||||||
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
|
cmd.Flags().StringVar(&template, "template", "", "built-in workbook template (cutover-portugal)")
|
||||||
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
|
cmd.Flags().StringVar(&title, "title", "", "upload file name when using --template")
|
||||||
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
|
cmd.Flags().StringVar(&keepLocal, "keep-xlsx", "", "also write generated/uploaded bytes to this local path")
|
||||||
cmd.Flags().BoolVar(&replace, "replace", true, "delete same-stem files before upload")
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// uploadProjectDoc upserts (--replace, default) or no-clobbers into project/folder Documents.
|
||||||
|
func uploadProjectDoc(ctx context.Context, c *onlyoffice.Client, pid, localPath, folderID string, replace bool) (*onlyoffice.FileEntry, []int, error) {
|
||||||
|
if folderID != "" {
|
||||||
|
if replace {
|
||||||
|
return c.UploadToFolderReplacing(ctx, folderID, localPath)
|
||||||
|
}
|
||||||
|
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ent, err := c.UploadToFolder(ctx, folderID, localPath)
|
||||||
|
return ent, nil, err
|
||||||
|
}
|
||||||
|
if replace {
|
||||||
|
return c.UploadProjectFileReplacing(ctx, pid, localPath)
|
||||||
|
}
|
||||||
|
ent, err := c.UploadProjectFileNoClobber(ctx, pid, localPath)
|
||||||
|
return ent, nil, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete
|
// 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 invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items …
|
||||||
// oo docs tools | convert | ocr | as-md | put-md
|
// 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
|
// CRM association rules: docs/crm-associations.md
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -107,9 +107,12 @@ func prjFilesListCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prjFilesUploadCmd() *cobra.Command {
|
func prjFilesUploadCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
var replace, allowDuplicate bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
Use: "upload PROJECT_ID LOCAL_PATH [LOCAL_PATH...]",
|
||||||
Short: "Upload file(s) into the project's Documents folder",
|
Short: "Upload file(s) into the project's Documents folder (upsert by stem|ext)",
|
||||||
|
Long: `Default: replace an existing file with the same logical name (stem|ext), like cp overwrite.
|
||||||
|
Pass --no-replace to fail when the name is taken; --allow-duplicate to always create a new file id.`,
|
||||||
Args: cobra.MinimumNArgs(2),
|
Args: cobra.MinimumNArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
c, err := newOO(cmd)
|
c, err := newOO(cmd)
|
||||||
@@ -118,15 +121,31 @@ func prjFilesUploadCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
pid := args[0]
|
pid := args[0]
|
||||||
for _, p := range args[1:] {
|
for _, p := range args[1:] {
|
||||||
entry, err := c.UploadProjectFile(cmd.Context(), pid, p)
|
var entry *onlyoffice.FileEntry
|
||||||
|
var deleted []int
|
||||||
|
switch {
|
||||||
|
case allowDuplicate:
|
||||||
|
entry, err = c.UploadProjectFile(cmd.Context(), pid, p)
|
||||||
|
case replace:
|
||||||
|
entry, deleted, err = c.UploadProjectFileReplacing(cmd.Context(), pid, p)
|
||||||
|
default:
|
||||||
|
entry, err = c.UploadProjectFileNoClobber(cmd.Context(), pid, p)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printObject(fileEntryToMap(entry))
|
obj := fileEntryToMap(entry)
|
||||||
|
if len(deleted) > 0 {
|
||||||
|
obj["replaced_file_ids"] = deleted
|
||||||
|
}
|
||||||
|
printObject(obj)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext in project folder (default)")
|
||||||
|
cmd.Flags().BoolVar(&allowDuplicate, "allow-duplicate", false, "always create a new file even when the name exists")
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func prjFilesDownloadCmd() *cobra.Command {
|
func prjFilesDownloadCmd() *cobra.Command {
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ListContacts returns a page of CRM contacts and the total count.
|
// ListContacts returns a page of CRM contacts and the total count.
|
||||||
|
// sortBy=id is always set: OnlyOffice filter.json without an explicit sort
|
||||||
|
// order is non-deterministic on large contact sets, so a paged walk
|
||||||
|
// (ListAllContacts, ListContactsByTag, FindCompany, FindPerson) can skip or
|
||||||
|
// duplicate contacts across page boundaries.
|
||||||
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
func (c *Client) ListContacts(ctx context.Context, count, startIndex int, search string) ([]map[string]any, int, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("count", strconv.Itoa(count))
|
q.Set("count", strconv.Itoa(count))
|
||||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
|
q.Set("sortBy", "id")
|
||||||
if search != "" {
|
if search != "" {
|
||||||
q.Set("filterValue", search)
|
q.Set("filterValue", search)
|
||||||
}
|
}
|
||||||
@@ -243,6 +248,24 @@ func (c *Client) DeleteContact(ctx context.Context, contactID string) (map[strin
|
|||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/%s.json", url.PathEscape(contactID)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateContactName renames a CRM company contact displayName.
|
||||||
|
// Uses the company endpoint (person names go through /crm/contact/person/{id}).
|
||||||
|
func (c *Client) UpdateContactName(ctx context.Context, contactID, newName string) (map[string]any, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"displayName": newName,
|
||||||
|
"companyName": newName,
|
||||||
|
"isCompany": true,
|
||||||
|
}
|
||||||
|
out, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/crm/contact/company/%s.json", url.PathEscape(contactID)), body)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if fresh, gerr := c.GetContact(ctx, contactID); gerr == nil && fresh != nil {
|
||||||
|
out = fresh
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
// ListContactTags returns all CRM contact tags (title + relativeItemsCount).
|
||||||
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
func (c *Client) ListContactTags(ctx context.Context) ([]map[string]any, error) {
|
||||||
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
return c.ResponseArray(ctx, "/api/2.0/crm/contact/tag.json")
|
||||||
@@ -288,6 +311,7 @@ func (c *Client) ListContactsByTag(ctx context.Context, tagName string, count, s
|
|||||||
q.Set("count", strconv.Itoa(count))
|
q.Set("count", strconv.Itoa(count))
|
||||||
q.Set("startIndex", strconv.Itoa(startIndex))
|
q.Set("startIndex", strconv.Itoa(startIndex))
|
||||||
q.Set("tags", tagName)
|
q.Set("tags", tagName)
|
||||||
|
q.Set("sortBy", "id")
|
||||||
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
raw, err := c.getJSON(ctx, "/api/2.0/crm/contact/filter.json?"+q.Encode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -717,6 +741,11 @@ func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any,
|
|||||||
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseCRMTask closes (completes) a CRM task via the task close endpoint.
|
||||||
|
func (c *Client) CloseCRMTask(ctx context.Context, id string) (map[string]any, error) {
|
||||||
|
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s/close.json", url.PathEscape(id)), url.Values{})
|
||||||
|
}
|
||||||
|
|
||||||
// ListTaskCategories returns CRM task categories.
|
// ListTaskCategories returns CRM task categories.
|
||||||
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
func (c *Client) ListTaskCategories(ctx context.Context) ([]map[string]any, error) {
|
||||||
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
return c.ResponseArray(ctx, "/api/2.0/crm/task/category.json")
|
||||||
|
|||||||
@@ -321,11 +321,21 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
|||||||
"folderIds": []int{},
|
"folderIds": []int{},
|
||||||
"fileIds": fileIDs,
|
"fileIds": fileIDs,
|
||||||
"destFolderId": destFolderID,
|
"destFolderId": destFolderID,
|
||||||
|
"resolveType": "Skip",
|
||||||
|
"holdResult": true,
|
||||||
}
|
}
|
||||||
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +356,24 @@ func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string)
|
|||||||
return decodeResponseFileEntry(raw)
|
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.
|
// FileFolderID returns the parent folder id string for a file entry, if known.
|
||||||
func FileFolderID(f *FileEntry) string {
|
func FileFolderID(f *FileEntry) string {
|
||||||
if f == nil || f.FolderID == nil {
|
if f == nil || f.FolderID == nil {
|
||||||
|
|||||||
+48
-2
@@ -2,6 +2,7 @@ package onlyoffice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -310,25 +311,70 @@ func (c *Client) DeleteFilesByDedupKey(ctx context.Context, folderID, stem, ext
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeProjectRootForDedupe includes projectFolder files in dedupe scans. OO often lists
|
||||||
|
// root documents only in pf.Files while pf.Folders is empty.
|
||||||
|
func mergeProjectRootForDedupe(rootID string, folders []*FolderEntry, filesByFolder map[string][]*FileEntry, rootFiles []*FileEntry) ([]*FolderEntry, map[string][]*FileEntry) {
|
||||||
|
if rootID == "" {
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
if filesByFolder == nil {
|
||||||
|
filesByFolder = map[string][]*FileEntry{}
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
if folder != nil && folder.ID != nil && folder.ID.String() == rootID {
|
||||||
|
if len(rootFiles) > 0 {
|
||||||
|
filesByFolder[rootID] = rootFiles
|
||||||
|
}
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rootFiles) == 0 {
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
id := json.Number(rootID)
|
||||||
|
title := "(project root)"
|
||||||
|
folders = append(folders, &FolderEntry{ID: &id, Title: &title})
|
||||||
|
filesByFolder[rootID] = rootFiles
|
||||||
|
return folders, filesByFolder
|
||||||
|
}
|
||||||
|
|
||||||
// DedupeProject scans project folders and optionally deletes duplicates.
|
// DedupeProject scans project folders and optionally deletes duplicates.
|
||||||
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
|
func (c *Client) DedupeProject(ctx context.Context, projectID string, opts DedupOptions, apply bool) ([]DedupGroup, []int, error) {
|
||||||
pf, err := c.GetProjectFiles(ctx, projectID)
|
pf, err := c.GetProjectFiles(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders))
|
rootID, err := c.projectFolderID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var rootFiles []*FileEntry
|
||||||
|
if rootID != "" {
|
||||||
|
rootFiles, err = c.FolderFiles(ctx, rootID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesByFolder := make(map[string][]*FileEntry, len(pf.Folders)+1)
|
||||||
|
folders := make([]*FolderEntry, 0, len(pf.Folders)+1)
|
||||||
for _, folder := range pf.Folders {
|
for _, folder := range pf.Folders {
|
||||||
if folder == nil || folder.ID == nil {
|
if folder == nil || folder.ID == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fid := folder.ID.String()
|
fid := folder.ID.String()
|
||||||
|
if fid == rootID {
|
||||||
|
filesByFolder[fid] = rootFiles
|
||||||
|
} else {
|
||||||
files, err := c.FolderFiles(ctx, fid)
|
files, err := c.FolderFiles(ctx, fid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
filesByFolder[fid] = files
|
filesByFolder[fid] = files
|
||||||
}
|
}
|
||||||
groups := FindProjectDuplicates(pf.Folders, filesByFolder, opts)
|
folders = append(folders, folder)
|
||||||
|
}
|
||||||
|
folders, filesByFolder = mergeProjectRootForDedupe(rootID, folders, filesByFolder, rootFiles)
|
||||||
|
groups := FindProjectDuplicates(folders, filesByFolder, opts)
|
||||||
if !apply || len(groups) == 0 {
|
if !apply || len(groups) == 0 {
|
||||||
return groups, nil, nil
|
return groups, nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ func TestCrossFolderPrefersNonTrash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeProjectRootForDedupe(t *testing.T) {
|
||||||
|
old := &FileEntry{ID: jsonNum("1"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
|
||||||
|
newer := &FileEntry{ID: jsonNum("2"), Title: strPtr("a.docx"), FileExst: strPtr(".docx")}
|
||||||
|
rootFiles := []*FileEntry{old, newer}
|
||||||
|
folders, byFolder := mergeProjectRootForDedupe("489", nil, nil, rootFiles)
|
||||||
|
if len(folders) != 1 || folders[0].ID.String() != "489" {
|
||||||
|
t.Fatalf("folders=%+v", folders)
|
||||||
|
}
|
||||||
|
if len(byFolder["489"]) != 2 {
|
||||||
|
t.Fatalf("root files=%d", len(byFolder["489"]))
|
||||||
|
}
|
||||||
|
groups := findWithinFolderDuplicates([]ProjectFolderFile{
|
||||||
|
{FolderID: "489", FolderTitle: "(project root)", File: old},
|
||||||
|
{FolderID: "489", FolderTitle: "(project root)", File: newer},
|
||||||
|
})
|
||||||
|
if len(groups) != 1 {
|
||||||
|
t.Fatalf("groups=%d", len(groups))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsTrashFolderTitle(t *testing.T) {
|
func TestIsTrashFolderTitle(t *testing.T) {
|
||||||
if !IsTrashFolderTitle("_trash-md") {
|
if !IsTrashFolderTitle("_trash-md") {
|
||||||
t.Fatal("expected trash")
|
t.Fatal("expected trash")
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ package onlyoffice
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrFileExists is returned when --no-replace / no-clobber upload hits an existing stem|ext.
|
||||||
|
var ErrFileExists = errors.New("onlyoffice: file already exists in folder (use replace or delete first)")
|
||||||
|
|
||||||
// FileEntryStem returns the logical basename without duplicated extensions.
|
// FileEntryStem returns the logical basename without duplicated extensions.
|
||||||
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
|
// OO often stores title="foo.docx" and fileExst=".docx" (UI shows foo.docx.docx).
|
||||||
func FileEntryStem(f *FileEntry) string {
|
func FileEntryStem(f *FileEntry) string {
|
||||||
@@ -116,6 +121,38 @@ func (c *Client) DeleteFilesByStem(ctx context.Context, folderID, stem string) (
|
|||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssertNoFileConflict reports ErrFileExists when localPath stem|ext is already in folderID.
|
||||||
|
func (c *Client) AssertNoFileConflict(ctx context.Context, folderID, localPath string) error {
|
||||||
|
files, err := c.FolderFiles(ctx, folderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stem := UploadStemFromLocal(localPath)
|
||||||
|
ext := UploadExtFromLocal(localPath)
|
||||||
|
matches := FindFilesByDedupKey(files, stem, ext)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(matches))
|
||||||
|
for _, f := range matches {
|
||||||
|
ids = append(ids, fmt.Sprintf("%d", FileEntryNumericID(f)))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %s%s in folder %s (existing file ids: %s)",
|
||||||
|
ErrFileExists, stem, ext, folderID, strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadProjectFileNoClobber uploads only when stem|ext is not already in the project folder.
|
||||||
|
func (c *Client) UploadProjectFileNoClobber(ctx context.Context, projectID, localPath string) (*FileEntry, error) {
|
||||||
|
folderID, err := c.projectFolderID(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.AssertNoFileConflict(ctx, folderID, localPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.UploadProjectFile(ctx, projectID, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
|
// UploadToFolderReplacing deletes same stem+ext files then uploads localPath.
|
||||||
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
|
func (c *Client) UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*FileEntry, []int, error) {
|
||||||
stem := UploadStemFromLocal(localPath)
|
stem := UploadStemFromLocal(localPath)
|
||||||
|
|||||||
+60
-2
@@ -144,28 +144,86 @@ func (c *Client) RenameDavFile(ctx context.Context, id, title string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MoveDavItems moves the given folders and/or files into destFolderID.
|
// 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 {
|
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),
|
"folderIds": nums(folderIDs),
|
||||||
"fileIds": nums(fileIDs),
|
"fileIds": nums(fileIDs),
|
||||||
"destFolderId": num(destFolderID),
|
"destFolderId": num(destFolderID),
|
||||||
"resolveType": "Skip",
|
"resolveType": "Skip",
|
||||||
"holdResult": true,
|
"holdResult": true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return fileopsError(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// CopyDavItems copies the given folders and/or files into destFolderID.
|
// 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 {
|
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),
|
"folderIds": nums(folderIDs),
|
||||||
"fileIds": nums(fileIDs),
|
"fileIds": nums(fileIDs),
|
||||||
"destFolderId": num(destFolderID),
|
"destFolderId": num(destFolderID),
|
||||||
"conflictResolveType": "Skip",
|
"conflictResolveType": "Skip",
|
||||||
"deleteAfter": true,
|
"deleteAfter": true,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
return err
|
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.
|
// DeleteDavItems deletes the given folders and/or files.
|
||||||
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
|
func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user