feat(files): canonical Entry + FileStore REST/DAV adapters (#35)
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 18s
Tests / Test (Go stable) (pull_request) Successful in 18s
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 18s
Tests / Test (Go stable) (pull_request) Successful in 18s
This commit is contained in:
+199
@@ -0,0 +1,199 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// Canonical file model and the backend-agnostic store interface. REST
|
||||||
|
// (files.go), WebDAV (files_webdav.go) and future backends (PostgreSQL,
|
||||||
|
// Elasticsearch) implement FileStore/Searcher so callers stop depending on a
|
||||||
|
// concrete transport. This file holds only types and pure conversions — no IO.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind distinguishes files from folders in the canonical model.
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
File Kind = iota
|
||||||
|
Folder
|
||||||
|
)
|
||||||
|
|
||||||
|
// String renders the kind for logs and table output.
|
||||||
|
func (k Kind) String() string {
|
||||||
|
switch k {
|
||||||
|
case File:
|
||||||
|
return "file"
|
||||||
|
case Folder:
|
||||||
|
return "folder"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider names for the FileStore adapters.
|
||||||
|
const (
|
||||||
|
ProviderREST = "rest"
|
||||||
|
ProviderDAV = "dav"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is the backend-independent representation of a document or folder.
|
||||||
|
// Fields that a backend cannot supply stay at their zero value.
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
ParentID string
|
||||||
|
Title string
|
||||||
|
Kind Kind
|
||||||
|
Size int64
|
||||||
|
MIME string
|
||||||
|
Created time.Time
|
||||||
|
Modified time.Time
|
||||||
|
Version int
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStore is the operation surface every file backend implements.
|
||||||
|
type FileStore interface {
|
||||||
|
Name() string
|
||||||
|
List(ctx context.Context, parentID string) ([]Entry, error)
|
||||||
|
Stat(ctx context.Context, id string) (Entry, error)
|
||||||
|
CreateFolder(ctx context.Context, parentID, title string) (Entry, error)
|
||||||
|
Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error)
|
||||||
|
Download(ctx context.Context, id string, w io.Writer) (int64, error)
|
||||||
|
Move(ctx context.Context, ids []string, parentID string) error
|
||||||
|
Copy(ctx context.Context, ids []string, parentID string) error
|
||||||
|
Rename(ctx context.Context, id, title string) error
|
||||||
|
Delete(ctx context.Context, ids []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchQuery narrows a Searcher request. InContent asks the backend to match
|
||||||
|
// document bodies, not just titles.
|
||||||
|
type SearchQuery struct {
|
||||||
|
Text string
|
||||||
|
InContent bool
|
||||||
|
FolderID string
|
||||||
|
Extensions []string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchHit is one Searcher result: the matching entry plus backend-specific
|
||||||
|
// ranking metadata.
|
||||||
|
type SearchHit struct {
|
||||||
|
Entry
|
||||||
|
Score float64
|
||||||
|
Highlight string
|
||||||
|
Path []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searcher is the optional content/name search surface. Only some backends
|
||||||
|
// (for example Elasticsearch) provide it.
|
||||||
|
type Searcher interface {
|
||||||
|
Search(ctx context.Context, q SearchQuery) ([]SearchHit, error)
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStore returns the adapter for a backend name: ProviderREST (default) or
|
||||||
|
// ProviderDAV. Unknown or empty names select the REST backend. The full facade
|
||||||
|
// (backend composition) is deliberately left to a later change.
|
||||||
|
func (c *Client) FileStore(backend string) FileStore {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(backend)) {
|
||||||
|
case ProviderDAV, "webdav":
|
||||||
|
return &davStore{c: c}
|
||||||
|
default:
|
||||||
|
return &restStore{c: c}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files returns the default (REST) file store.
|
||||||
|
func (c *Client) Files() FileStore { return c.FileStore(ProviderREST) }
|
||||||
|
|
||||||
|
// retryStoreOp runs one store operation under the shared deterministic
|
||||||
|
// transient-error policy (429/502/503/504).
|
||||||
|
func retryStoreOp(ctx context.Context, fn func() error) error {
|
||||||
|
return DoRetry(ctx, DefaultRetryPolicy(), fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntryToEntry converts a Files-module file row to the canonical model.
|
||||||
|
func FileEntryToEntry(f *FileEntry, provider string) Entry {
|
||||||
|
e := Entry{Kind: File, Provider: provider}
|
||||||
|
if f == nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if f.ID != nil {
|
||||||
|
e.ID = f.ID.String()
|
||||||
|
}
|
||||||
|
e.ParentID = FileFolderID(f)
|
||||||
|
if f.Title != nil {
|
||||||
|
e.Title = *f.Title
|
||||||
|
}
|
||||||
|
if f.ContentLength != nil {
|
||||||
|
e.Size = parseContentLength(*f.ContentLength)
|
||||||
|
}
|
||||||
|
exst := ""
|
||||||
|
if f.FileExst != nil {
|
||||||
|
exst = *f.FileExst
|
||||||
|
}
|
||||||
|
e.MIME = mimeForTitle(e.Title, exst)
|
||||||
|
if f.Updated != nil {
|
||||||
|
e.Modified = *f.Updated
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// DavFileToEntry converts a WebDAV file row to the canonical model.
|
||||||
|
func DavFileToEntry(f DavFile, provider string) Entry {
|
||||||
|
return Entry{
|
||||||
|
ID: f.ID,
|
||||||
|
Title: f.Title,
|
||||||
|
Kind: File,
|
||||||
|
Size: f.Size,
|
||||||
|
MIME: mimeForTitle(f.Title, ""),
|
||||||
|
Modified: f.ModTime(),
|
||||||
|
Provider: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DavFolderToEntry converts a WebDAV folder row to the canonical model.
|
||||||
|
func DavFolderToEntry(f DavFolder, provider string) Entry {
|
||||||
|
return Entry{
|
||||||
|
ID: f.ID,
|
||||||
|
ParentID: f.ParentID,
|
||||||
|
Title: f.Title,
|
||||||
|
Kind: Folder,
|
||||||
|
Modified: f.ModTime(),
|
||||||
|
Provider: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContentLength reads the leading integer of an OnlyOffice contentLength
|
||||||
|
// string (the API sometimes appends a unit, e.g. "12345 b").
|
||||||
|
func parseContentLength(s string) int64 {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(fields[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// mimeForTitle derives a MIME type from an explicit extension or the title.
|
||||||
|
func mimeForTitle(title, exst string) string {
|
||||||
|
ext := strings.TrimSpace(exst)
|
||||||
|
if ext == "" {
|
||||||
|
ext = filepath.Ext(title)
|
||||||
|
}
|
||||||
|
if ext == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
return mime.TypeByExtension(strings.ToLower(ext))
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileEntryToEntry(t *testing.T) {
|
||||||
|
id := json.Number("42")
|
||||||
|
title := "invoice.pdf"
|
||||||
|
exst := ".pdf"
|
||||||
|
size := "12345"
|
||||||
|
parent := json.Number("7")
|
||||||
|
updated := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
f := &FileEntry{
|
||||||
|
ID: &id,
|
||||||
|
Title: &title,
|
||||||
|
FileExst: &exst,
|
||||||
|
ContentLength: &size,
|
||||||
|
FolderID: &parent,
|
||||||
|
Updated: &updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
e := FileEntryToEntry(f, ProviderREST)
|
||||||
|
if e.ID != "42" {
|
||||||
|
t.Errorf("ID = %q, want 42", e.ID)
|
||||||
|
}
|
||||||
|
if e.ParentID != "7" {
|
||||||
|
t.Errorf("ParentID = %q, want 7", e.ParentID)
|
||||||
|
}
|
||||||
|
if e.Title != title {
|
||||||
|
t.Errorf("Title = %q, want %q", e.Title, title)
|
||||||
|
}
|
||||||
|
if e.Kind != File {
|
||||||
|
t.Errorf("Kind = %v, want file", e.Kind)
|
||||||
|
}
|
||||||
|
if e.Size != 12345 {
|
||||||
|
t.Errorf("Size = %d, want 12345", e.Size)
|
||||||
|
}
|
||||||
|
if e.MIME != "application/pdf" {
|
||||||
|
t.Errorf("MIME = %q, want application/pdf", e.MIME)
|
||||||
|
}
|
||||||
|
if !e.Modified.Equal(updated) {
|
||||||
|
t.Errorf("Modified = %v, want %v", e.Modified, updated)
|
||||||
|
}
|
||||||
|
if e.Provider != ProviderREST {
|
||||||
|
t.Errorf("Provider = %q, want %q", e.Provider, ProviderREST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileEntryToEntryNil(t *testing.T) {
|
||||||
|
e := FileEntryToEntry(nil, ProviderDAV)
|
||||||
|
if e.Kind != File {
|
||||||
|
t.Errorf("Kind = %v, want file", e.Kind)
|
||||||
|
}
|
||||||
|
if e.ID != "" || e.Title != "" {
|
||||||
|
t.Errorf("nil entry should be empty: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Provider != ProviderDAV {
|
||||||
|
t.Errorf("Provider = %q, want %q", e.Provider, ProviderDAV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileEntryToEntrySizeFormats(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"12345": 12345,
|
||||||
|
"12345 b": 12345,
|
||||||
|
"0": 0,
|
||||||
|
"": 0,
|
||||||
|
"notanum": 0,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
got := parseContentLength(in)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("parseContentLength(%q) = %d, want %d", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDavFileToEntry(t *testing.T) {
|
||||||
|
f := DavFile{
|
||||||
|
ID: "9",
|
||||||
|
Title: "note.txt",
|
||||||
|
Size: 10,
|
||||||
|
Updated: "2026-01-02T03:04:05.0000000+01:00",
|
||||||
|
}
|
||||||
|
e := DavFileToEntry(f, ProviderDAV)
|
||||||
|
if e.ID != "9" || e.Title != "note.txt" {
|
||||||
|
t.Errorf("identity mismatch: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Kind != File {
|
||||||
|
t.Errorf("Kind = %v, want file", e.Kind)
|
||||||
|
}
|
||||||
|
if e.Size != 10 {
|
||||||
|
t.Errorf("Size = %d, want 10", e.Size)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(e.MIME, "text/plain") {
|
||||||
|
t.Errorf("MIME = %q, want text/plain*", e.MIME)
|
||||||
|
}
|
||||||
|
if e.Modified.IsZero() {
|
||||||
|
t.Error("Modified not parsed")
|
||||||
|
}
|
||||||
|
if e.Provider != ProviderDAV {
|
||||||
|
t.Errorf("Provider = %q, want %q", e.Provider, ProviderDAV)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDavFolderToEntry(t *testing.T) {
|
||||||
|
f := DavFolder{
|
||||||
|
ID: "5",
|
||||||
|
Title: "inbox",
|
||||||
|
ParentID: "1",
|
||||||
|
Updated: "2026-01-02T03:04:05.0000000+01:00",
|
||||||
|
}
|
||||||
|
e := DavFolderToEntry(f, ProviderDAV)
|
||||||
|
if e.ID != "5" || e.Title != "inbox" || e.ParentID != "1" {
|
||||||
|
t.Errorf("identity mismatch: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Kind != Folder {
|
||||||
|
t.Errorf("Kind = %v, want folder", e.Kind)
|
||||||
|
}
|
||||||
|
if e.MIME != "" {
|
||||||
|
t.Errorf("folder MIME = %q, want empty", e.MIME)
|
||||||
|
}
|
||||||
|
if e.Modified.IsZero() {
|
||||||
|
t.Error("Modified not parsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntriesFromFolderMap(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"files": []any{
|
||||||
|
map[string]any{"id": float64(42), "title": "a.pdf", "pureContentLength": float64(7)},
|
||||||
|
},
|
||||||
|
"folders": []any{
|
||||||
|
map[string]any{"id": float64(7), "title": "sub", "parentId": float64(1)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entries, err := entriesFromFolderMap(m, ProviderREST)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("entriesFromFolderMap: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("got %d entries, want 2: %+v", len(entries), entries)
|
||||||
|
}
|
||||||
|
byID := map[string]Entry{}
|
||||||
|
for _, e := range entries {
|
||||||
|
byID[e.ID] = e
|
||||||
|
}
|
||||||
|
if got := byID["42"]; got.Kind != File || got.Size != 7 || got.Title != "a.pdf" {
|
||||||
|
t.Errorf("file entry = %+v", got)
|
||||||
|
}
|
||||||
|
if got := byID["7"]; got.Kind != Folder || got.ParentID != "1" || got.Title != "sub" {
|
||||||
|
t.Errorf("folder entry = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntriesFromFolderMapNil(t *testing.T) {
|
||||||
|
entries, err := entriesFromFolderMap(nil, ProviderREST)
|
||||||
|
if err != nil || entries != nil {
|
||||||
|
t.Fatalf("got %v, %v; want nil, nil", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKindString(t *testing.T) {
|
||||||
|
if File.String() != "file" || Folder.String() != "folder" {
|
||||||
|
t.Errorf("kind strings: %q %q", File.String(), Folder.String())
|
||||||
|
}
|
||||||
|
if Kind(9).String() != "unknown" {
|
||||||
|
t.Errorf("unknown kind = %q", Kind(9).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFileStoreSelection(t *testing.T) {
|
||||||
|
c := NewClient(Credentials{})
|
||||||
|
if got := c.FileStore(ProviderDAV).Name(); got != ProviderDAV {
|
||||||
|
t.Errorf("FileStore(dav).Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.FileStore("webdav").Name(); got != ProviderDAV {
|
||||||
|
t.Errorf("FileStore(webdav).Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.FileStore(ProviderREST).Name(); got != ProviderREST {
|
||||||
|
t.Errorf("FileStore(rest).Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.FileStore("").Name(); got != ProviderREST {
|
||||||
|
t.Errorf("FileStore(\"\").Name() = %q", got)
|
||||||
|
}
|
||||||
|
if got := c.Files().Name(); got != ProviderREST {
|
||||||
|
t.Errorf("Files().Name() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// davStore implements FileStore on top of the Documents/WebDAV methods in
|
||||||
|
// files_webdav.go. The Documents fileops calls need folder and file ids
|
||||||
|
// separated, so ids are classified through Stat before move/copy/rename/delete.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// davStore is a FileStore over the WebDAV-oriented Documents API.
|
||||||
|
type davStore struct{ c *Client }
|
||||||
|
|
||||||
|
// Name reports the backend name.
|
||||||
|
func (s *davStore) Name() string { return ProviderDAV }
|
||||||
|
|
||||||
|
// List returns the files and folders directly below parentID.
|
||||||
|
func (s *davStore) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||||
|
var out []Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
l, err := s.c.ListDavFolder(ctx, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries := make([]Entry, 0, len(l.Folders)+len(l.Files))
|
||||||
|
for _, f := range l.Folders {
|
||||||
|
entries = append(entries, DavFolderToEntry(f, ProviderDAV))
|
||||||
|
}
|
||||||
|
for _, f := range l.Files {
|
||||||
|
entries = append(entries, DavFileToEntry(f, ProviderDAV))
|
||||||
|
}
|
||||||
|
out = entries
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat resolves a folder or file entry by id. A folder answers ListDavFolder
|
||||||
|
// with its own metadata in Current; otherwise the file metadata API is used.
|
||||||
|
func (s *davStore) Stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
return s.stat(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder creates a subfolder under parentID.
|
||||||
|
func (s *davStore) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
f, err := s.c.CreateDavFolder(ctx, parentID, title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
return fmt.Errorf("onlyoffice: dav store: empty create-folder response")
|
||||||
|
}
|
||||||
|
out = DavFolderToEntry(*f, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload streams r into parentID as title. The reader is buffered once so a
|
||||||
|
// retry re-sends the same bytes instead of an exhausted stream.
|
||||||
|
func (s *davStore) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
var out Entry
|
||||||
|
err = retryStoreOp(ctx, func() error {
|
||||||
|
f, err := s.c.UploadDavFile(ctx, parentID, title, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f == nil {
|
||||||
|
return fmt.Errorf("onlyoffice: dav store: empty upload response")
|
||||||
|
}
|
||||||
|
out = DavFileToEntry(*f, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the file bytes into w.
|
||||||
|
func (s *davStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
var e error
|
||||||
|
n, e = s.c.DownloadDavFile(ctx, id, w)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves ids into parentID, splitting folders from files.
|
||||||
|
func (s *davStore) Move(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
folders, files, err := s.split(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.MoveDavItems(ctx, folders, files, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy copies ids into parentID, splitting folders from files.
|
||||||
|
func (s *davStore) Copy(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
folders, files, err := s.split(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.CopyDavItems(ctx, folders, files, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename renames a folder or file.
|
||||||
|
func (s *davStore) Rename(ctx context.Context, id, title string) error {
|
||||||
|
e, err := s.stat(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
if e.Kind == Folder {
|
||||||
|
return s.c.RenameDavFolder(ctx, id, title)
|
||||||
|
}
|
||||||
|
return s.c.RenameDavFile(ctx, id, title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes ids, splitting folders from files.
|
||||||
|
func (s *davStore) Delete(ctx context.Context, ids []string) error {
|
||||||
|
folders, files, err := s.split(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(folders) == 0 && len(files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.DeleteDavItems(ctx, folders, files)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stat resolves a single id to a folder or file Entry.
|
||||||
|
func (s *davStore) stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
if l, err := s.c.ListDavFolder(ctx, id); err == nil {
|
||||||
|
if l != nil && l.Current.ID != "" && l.Current.ID == id {
|
||||||
|
out = DavFolderToEntry(l.Current, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if Transient(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := s.c.GetFile(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = FileEntryToEntry(f, ProviderDAV)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// split classifies ids into folder and file id lists.
|
||||||
|
func (s *davStore) split(ctx context.Context, ids []string) (folders, files []string, err error) {
|
||||||
|
for _, id := range ids {
|
||||||
|
e, err := s.stat(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if e.Kind == Folder {
|
||||||
|
folders = append(folders, id)
|
||||||
|
} else {
|
||||||
|
files = append(files, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folders, files, nil
|
||||||
|
}
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
// restStore implements FileStore on top of the REST Documents methods in
|
||||||
|
// files.go. It is a thin adapter: no endpoint logic lives here, and every call
|
||||||
|
// is wrapped in DoRetry.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restStore is a FileStore over the REST Documents API.
|
||||||
|
type restStore struct{ c *Client }
|
||||||
|
|
||||||
|
// Name reports the backend name.
|
||||||
|
func (s *restStore) Name() string { return ProviderREST }
|
||||||
|
|
||||||
|
// List returns the files and folders directly below parentID.
|
||||||
|
func (s *restStore) List(ctx context.Context, parentID string) ([]Entry, error) {
|
||||||
|
var out []Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
raw, err := s.c.ListFolder(ctx, parentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries, err := entriesFromFolderMap(raw, ProviderREST)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = entries
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file metadata. The REST adapter resolves files only; folders
|
||||||
|
// are listed by their parent (use List).
|
||||||
|
func (s *restStore) Stat(ctx context.Context, id string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
f, err := s.c.GetFile(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = FileEntryToEntry(f, ProviderREST)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder creates a subfolder under parentID.
|
||||||
|
func (s *restStore) CreateFolder(ctx context.Context, parentID, title string) (Entry, error) {
|
||||||
|
var out Entry
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
m, err := s.c.CreateFolder(ctx, parentID, title)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e, err := folderEntryFromMap(m, parentID, ProviderREST)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.ParentID == "" {
|
||||||
|
e.ParentID = parentID
|
||||||
|
}
|
||||||
|
if e.Title == "" {
|
||||||
|
e.Title = title
|
||||||
|
}
|
||||||
|
out = e
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload streams r into parentID as title. UploadToFolder is path based, so
|
||||||
|
// the reader is spooled to a temporary file first (ponytail: OnlyOffice
|
||||||
|
// multipart upload buffers the whole body anyway).
|
||||||
|
func (s *restStore) Upload(ctx context.Context, parentID, title string, r io.Reader) (Entry, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "oo-rest-upload-")
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
local := filepath.Join(dir, SafeLocalFileName(title))
|
||||||
|
f, err := os.Create(local)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, r); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out Entry
|
||||||
|
err = retryStoreOp(ctx, func() error {
|
||||||
|
fe, err := s.c.UploadToFolder(ctx, parentID, local)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = FileEntryToEntry(fe, ProviderREST)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the file bytes into w.
|
||||||
|
func (s *restStore) Download(ctx context.Context, id string, w io.Writer) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := retryStoreOp(ctx, func() error {
|
||||||
|
var e error
|
||||||
|
n, e = s.c.DownloadFile(ctx, id, w)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves file ids into parentID. The REST MoveFiles endpoint handles files
|
||||||
|
// only; folder moves are not exposed by this adapter.
|
||||||
|
func (s *restStore) Move(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
dest, err := strconv.Atoi(strings.TrimSpace(parentID))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("onlyoffice: rest store: move: non-numeric destination folder id %q", parentID)
|
||||||
|
}
|
||||||
|
fileIDs, err := numericIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
_, err := s.c.MoveFiles(ctx, dest, fileIDs)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy copies file ids into parentID. files.go has no copy method, so the
|
||||||
|
// shared REST fileops copy endpoint (CopyDavItems) is used.
|
||||||
|
func (s *restStore) Copy(ctx context.Context, ids []string, parentID string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.CopyDavItems(ctx, nil, ids, parentID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename sets a new title (including extension) for a file.
|
||||||
|
func (s *restStore) Rename(ctx context.Context, id, title string) error {
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
_, err := s.c.RenameFile(ctx, id, title)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete permanently deletes file ids.
|
||||||
|
func (s *restStore) Delete(ctx context.Context, ids []string) error {
|
||||||
|
fileIDs, err := numericIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(fileIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return retryStoreOp(ctx, func() error {
|
||||||
|
return s.c.DeleteFiles(ctx, fileIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// entriesFromFolderMap converts a ListFolder response map into canonical
|
||||||
|
// entries, reusing the DavFile/DavFolder decoders for robust size handling.
|
||||||
|
func entriesFromFolderMap(m map[string]any, provider string) ([]Entry, error) {
|
||||||
|
if m == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var listing DavListing
|
||||||
|
if err := json.Unmarshal(b, &listing); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Entry, 0, len(listing.Folders)+len(listing.Files))
|
||||||
|
for _, f := range listing.Folders {
|
||||||
|
out = append(out, DavFolderToEntry(f, provider))
|
||||||
|
}
|
||||||
|
for _, f := range listing.Files {
|
||||||
|
out = append(out, DavFileToEntry(f, provider))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderEntryFromMap converts a CreateFolder response map into a folder Entry.
|
||||||
|
func folderEntryFromMap(m map[string]any, parentID, provider string) (Entry, error) {
|
||||||
|
e := Entry{Kind: Folder, Provider: provider, ParentID: parentID}
|
||||||
|
if m == nil {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
var f DavFolder
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e = DavFolderToEntry(f, provider)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericIDs parses Documents numeric ids from strings.
|
||||||
|
func numericIDs(ids []string) ([]int, error) {
|
||||||
|
out := make([]int, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("onlyoffice: rest store: non-numeric id %q", id)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package onlyoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationFileStores runs the same operation set (create folder, upload,
|
||||||
|
// list, stat, download, move, copy, rename, delete) through the REST and DAV
|
||||||
|
// FileStore adapters against a throwaway project Documents folder. Destructive
|
||||||
|
// — only run against instances you own.
|
||||||
|
//
|
||||||
|
// The Documents fileops API is asynchronous: a move/copy/delete is accepted
|
||||||
|
// immediately and becomes visible a moment later, so effects are polled.
|
||||||
|
func TestIntegrationFileStores(t *testing.T) {
|
||||||
|
c := liveClient(t)
|
||||||
|
t.Cleanup(func() { cleanupTestProjects(t, c) })
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
suffix := time.Now().UTC().Format("20060102-150405")
|
||||||
|
project, err := c.CreateProject(NewProjectRequest{
|
||||||
|
Title: testProjectPrefix + "store-" + suffix,
|
||||||
|
Description: "go-onlyoffice file store integration",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProject: %v", err)
|
||||||
|
}
|
||||||
|
if project.ID == nil {
|
||||||
|
t.Fatal("created project without id")
|
||||||
|
}
|
||||||
|
root, err := c.projectFolderID(ctx, strconv.Itoa(*project.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("projectFolderID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, backend := range []string{ProviderREST, ProviderDAV} {
|
||||||
|
t.Run(backend, func(t *testing.T) {
|
||||||
|
testFileStoreOps(t, ctx, c, c.FileStore(backend), root, suffix)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileStoreOps(t *testing.T, ctx context.Context, c *Client, store FileStore, root, suffix string) {
|
||||||
|
t.Helper()
|
||||||
|
content := []byte("file store " + store.Name() + " " + suffix + "\n")
|
||||||
|
|
||||||
|
src, err := store.CreateFolder(ctx, root, "fs-src-"+suffix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFolder src: %v", err)
|
||||||
|
}
|
||||||
|
if src.Kind != Folder || src.ID == "" {
|
||||||
|
t.Fatalf("created src folder: %+v", src)
|
||||||
|
}
|
||||||
|
dst, err := store.CreateFolder(ctx, root, "fs-dst-"+suffix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFolder dst: %v", err)
|
||||||
|
}
|
||||||
|
if dst.Kind != Folder || dst.ID == "" {
|
||||||
|
t.Fatalf("created dst folder: %+v", dst)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := c.DeleteDavItems(ctx, []string{src.ID, dst.ID}, nil); err != nil {
|
||||||
|
t.Logf("cleanup folders: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
up, err := store.Upload(ctx, src.ID, "doc-"+suffix+".txt", bytes.NewReader(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Upload: %v", err)
|
||||||
|
}
|
||||||
|
if up.Kind != File || up.ID == "" {
|
||||||
|
t.Fatalf("uploaded entry: %+v", up)
|
||||||
|
}
|
||||||
|
if !waitEntry(ctx, store, src.ID, up.ID, 15*time.Second) {
|
||||||
|
t.Fatalf("uploaded %s not listed in src", up.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := store.Stat(ctx, up.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
if st.ID != up.ID || st.Kind != File {
|
||||||
|
t.Fatalf("stat = %+v", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
n, err := store.Download(ctx, up.ID, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Download: %v", err)
|
||||||
|
}
|
||||||
|
if n != int64(len(content)) || !bytes.Equal(buf.Bytes(), content) {
|
||||||
|
t.Fatalf("download mismatch: got %d bytes %q want %d", n, buf.String(), len(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
moveEventually(t, ctx, store, up.ID, dst.ID)
|
||||||
|
if !waitEntry(ctx, store, dst.ID, up.ID, 20*time.Second) {
|
||||||
|
t.Fatalf("moved file %s not in dst", up.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Copy(ctx, []string{up.ID}, src.ID); err != nil {
|
||||||
|
t.Fatalf("Copy: %v", err)
|
||||||
|
}
|
||||||
|
copied := waitOtherFile(ctx, store, src.ID, up.ID, 20*time.Second)
|
||||||
|
if copied == nil {
|
||||||
|
t.Fatalf("no copy found in src after Copy")
|
||||||
|
}
|
||||||
|
|
||||||
|
newTitle := "renamed-" + suffix + ".txt"
|
||||||
|
renameEventually(t, ctx, store, up.ID, newTitle)
|
||||||
|
|
||||||
|
if err := store.Delete(ctx, []string{up.ID, copied.ID}); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if !waitNoEntry(ctx, store, dst.ID, up.ID, 20*time.Second) {
|
||||||
|
t.Fatalf("file %s still present in dst after delete", up.ID)
|
||||||
|
}
|
||||||
|
if !waitNoEntry(ctx, store, src.ID, copied.ID, 20*time.Second) {
|
||||||
|
t.Fatalf("copy %s still present in src after delete", copied.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveEventually issues Move and retries while the operation is not visible yet
|
||||||
|
// (the fileops API accepts asynchronously and occasionally rejects a move that
|
||||||
|
// raced the just-finished upload).
|
||||||
|
func moveEventually(t *testing.T, ctx context.Context, store FileStore, id, dstID string) {
|
||||||
|
t.Helper()
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
if lastErr = store.Move(ctx, []string{id}, dstID); lastErr == nil {
|
||||||
|
if waitEntry(ctx, store, dstID, id, 6*time.Second) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
t.Fatalf("Move %s -> %s: %v", id, dstID, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameEventually(t *testing.T, ctx context.Context, store FileStore, id, title string) {
|
||||||
|
t.Helper()
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
if lastErr = store.Rename(ctx, id, title); lastErr == nil {
|
||||||
|
if e, err := store.Stat(ctx, id); err == nil && e.Title == title {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
t.Fatalf("Rename %s -> %q: %v", id, title, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitEntry(ctx context.Context, store FileStore, parentID, id string, d time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if list, err := store.List(ctx, parentID); err == nil && entryByID(list, id) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitNoEntry(ctx context.Context, store FileStore, parentID, id string, d time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if list, err := store.List(ctx, parentID); err == nil && entryByID(list, id) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitOtherFile(ctx context.Context, store FileStore, parentID, id string, d time.Duration) *Entry {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if list, err := store.List(ctx, parentID); err == nil {
|
||||||
|
if e := firstFileOtherThan(list, id); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryByID(entries []Entry, id string) *Entry {
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].ID == id {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstFileOtherThan(entries []Entry, id string) *Entry {
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].Kind == File && entries[i].ID != id {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -324,19 +324,22 @@ func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int)
|
|||||||
"resolveType": "Skip",
|
"resolveType": "Skip",
|
||||||
"holdResult": true,
|
"holdResult": true,
|
||||||
}
|
}
|
||||||
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
// fileops/move answers an operations envelope (like MoveDavItems), not a
|
||||||
|
// single object, so parse the raw body before unwrapping and surface any
|
||||||
|
// per-operation error. Unwrapping first (putJSONObject) made fileopsError
|
||||||
|
// look for a "response" key that was already stripped.
|
||||||
|
raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
raw, err = c.putJSON(ctx, "/api/2.0/files/fileops/move.json", body)
|
||||||
}
|
if err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if raw, merr := json.Marshal(out); merr == nil {
|
|
||||||
if ferr := fileopsError(raw); ferr != nil {
|
|
||||||
return nil, ferr
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, err
|
if ferr := fileopsError(raw); ferr != nil {
|
||||||
|
return nil, ferr
|
||||||
|
}
|
||||||
|
out, _ := unmarshalResponseObject(raw)
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
// UploadToFolder uploads a local file into an arbitrary Documents folder id.
|
||||||
|
|||||||
Reference in New Issue
Block a user