Merge pull request 'feat(dav): oo dav upload (локальный файл) + ensure-path (mkdir -p) (#80)' (#81) from feat/dav-upload#80 into main
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Successful in 6s
Tests / Test (Go stable) (push) Successful in 1m10s
Tests / Test (Go 1.25) (push) Successful in 1m15s

This commit was merged in pull request #81.
This commit is contained in:
2026-09-25 15:08:36 +01:00
6 changed files with 582 additions and 3 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea): - **CLI — `cmd/oo/` as `package main`.** Cobra wrapper that loads `.env` via `godotenv` at startup. **Subject-based command tree** mirroring [`tea`](https://gitea.com/gitea/tea):
- `main.go` — entry point (docstring lists the command tree). - `main.go` — entry point (docstring lists the command tree).
- `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag. - `common.go` — `rootCmd`, `newOO`, `printTable`/`printObject`, `--output table|json` flag.
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)). - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm.go`, `crm_tasks.go`, `catalog.go`, `docs.go`, `dav.go`, `search.go`, `index.go`, `mails.go`, `invoices.go` — one file per subject (or per subject facet), each registers in `init()`. `dav.go` exposes the Documents module by id (`oo dav ls|move|copy|mkdir|ensure-path|upload|rename-file|rename-folder|download|fileops`); `search.go` runs `oo search QUERY` (name/content, `--backend oo|own`); `index.go` fills the own full-text index (`oo index folder|files`, see [`docs/unified-file-client.md`](docs/unified-file-client.md)).
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`. - **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables. - **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
+10 -1
View File
@@ -741,6 +741,10 @@ surfaces such per-operation errors instead of a silent nil
oo dav ls 659 oo dav ls 659
oo dav ls @root # virtual sections (Documents, Projects, …) oo dav ls @root # virtual sections (Documents, Projects, …)
oo dav mkdir 659 "2026 inbox" oo dav mkdir 659 "2026 inbox"
oo dav ensure-path "Banks/Caixa" # resolve-or-create; idempotent, prints the folder id
oo dav ensure-path "Banks/Caixa" --under 659 # start under an explicit folder id
oo dav upload 659 ./historico.xlsx # multipart upload, upsert by stem|ext
oo dav upload 659 ./a.xlsx ./b.pdf --replace=false # fail on name conflict instead of replacing
oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID… oo dav move 659 22881 22882 # DEST_FOLDER_ID FILE_ID…
oo dav move 659 22881 --folders 670 # move folders along with files oo dav move 659 22881 --folders 670 # move folders along with files
oo dav copy 659 22881 oo dav copy 659 22881
@@ -750,6 +754,11 @@ oo dav download 22881 --to ./copy.pdf # default path: ./<server title>
oo dav fileops # active move/copy operations (status polling) oo dav fileops # active move/copy operations (status polling)
``` ```
`ensure-path` defaults to the concrete **My documents** section id (resolved
from `@root`, `rootFolderType=5`); `--under FOLDER_ID` overrides it. `upload`
reuses `UploadToFolderReplacing` (`--replace`, default) or `UploadToFolder`
after an `AssertNoFileConflict` check (`--no-replace`), so no new HTTP paths.
### Search and index (`oo search`, `oo index`) ### Search and index (`oo search`, `oo index`)
Full-text search over the Documents index. The REST endpoint Full-text search over the Documents index. The REST endpoint
@@ -824,7 +833,7 @@ library. They build on the public client and the same `DoRetry` pacing.
| `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` | | `crm-tasks` | `list`, `create`, `delete`, `categories`, `reassign-self` |
| `docs` | `tools`, `convert`, `pdf`, `presigned`, `csv`, `json`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` | | `docs` | `tools`, `convert`, `pdf`, `presigned`, `csv`, `json`, `optimize`, `ocr`, `hocr`, `as-md`, `put-md`, `put-txt`, `put-xlsx` |
| `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` | | `catalog` | `match`, `merge`, `apply`, `scan-contacts`, `scan-projects`, `scan-thunderbird` |
| `dav` | `ls`, `move`, `copy`, `mkdir`, `rename-file`, `rename-folder`, `download`, `fileops` | | `dav` | `ls`, `move`, `copy`, `mkdir`, `ensure-path`, `upload`, `rename-file`, `rename-folder`, `download`, `fileops` |
| `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) | | `search` | `QUERY` (`--content`, `--folder ID`, `--limit N`, `--backend oo\|own`, `--json`) |
| `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) | | `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) |
+189
View File
@@ -1,8 +1,10 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
@@ -26,6 +28,8 @@ func davCmd() *cobra.Command {
cmd.AddCommand(davMoveCmd()) cmd.AddCommand(davMoveCmd())
cmd.AddCommand(davCopyCmd()) cmd.AddCommand(davCopyCmd())
cmd.AddCommand(davMkdirCmd()) cmd.AddCommand(davMkdirCmd())
cmd.AddCommand(davEnsurePathCmd())
cmd.AddCommand(davUploadCmd())
cmd.AddCommand(davRemoveCmd()) cmd.AddCommand(davRemoveCmd())
cmd.AddCommand(davRenameFileCmd()) cmd.AddCommand(davRenameFileCmd())
cmd.AddCommand(davRenameFolderCmd()) cmd.AddCommand(davRenameFolderCmd())
@@ -187,6 +191,191 @@ func davMkdirCmd() *cobra.Command {
} }
} }
func davEnsurePathCmd() *cobra.Command {
var under string
cmd := &cobra.Command{
Use: "ensure-path PATH",
Short: "Resolve or create nested Documents folders (mkdir -p), print the final folder id",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
root := under
if root == "" {
root, err = myDocumentsID(cmd.Context(), c)
if err != nil {
return err
}
}
f, err := ensurePath(cmd.Context(), c.FileStore(onlyoffice.ProviderDAV), root, args[0])
if err != nil {
return err
}
printObject(map[string]any{"id": f.ID, "title": f.Title, "under": root})
return nil
},
}
cmd.Flags().StringVar(&under, "under", "", "parent folder id (default: My documents)")
return cmd
}
func davUploadCmd() *cobra.Command {
var replace bool
cmd := &cobra.Command{
Use: "upload DEST_FOLDER_ID LOCAL_FILE [LOCAL_FILE...]",
Short: "Upload local file(s) into a Documents folder (multipart, upsert by name)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
results, err := uploadLocal(cmd.Context(), c, args[0], args[1:], replace)
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(results))
for _, r := range results {
replaced := make([]string, 0, len(r.Replaced))
for _, id := range r.Replaced {
replaced = append(replaced, fmt.Sprint(id))
}
rows = append(rows, map[string]any{
"id": r.Entry.ID,
"title": r.Entry.Title,
"size": r.Entry.Size,
"replaced": strings.Join(replaced, ", "),
})
}
printTable([]string{"id", "title", "size", "replaced"}, rows)
return nil
},
}
cmd.Flags().BoolVar(&replace, "replace", true, "replace same stem|ext before upload (default); false = fail if name taken")
return cmd
}
// uploadedFile pairs an upload result with the ids deleted to make room for it.
type uploadedFile struct {
Entry onlyoffice.Entry
Replaced []int
}
// uploader is the slice of *onlyoffice.Client that dav upload needs, so the
// command logic is unit-testable with a fake.
type uploader interface {
UploadToFolderReplacing(ctx context.Context, folderID, localPath string) (*onlyoffice.FileEntry, []int, error)
AssertNoFileConflict(ctx context.Context, folderID, localPath string) error
UploadToFolder(ctx context.Context, folderID, localPath string) (*onlyoffice.FileEntry, error)
}
// uploadLocal uploads each local file into folderID. With replace a same
// stem|ext file is removed first (the UploadToFolderReplacing upsert); without
// replace a name clash fails with ErrFileExists before anything is sent.
func uploadLocal(ctx context.Context, up uploader, folderID string, paths []string, replace bool) ([]uploadedFile, error) {
results := make([]uploadedFile, 0, len(paths))
for _, path := range paths {
var (
fe *onlyoffice.FileEntry
deleted []int
err error
)
if replace {
fe, deleted, err = up.UploadToFolderReplacing(ctx, folderID, path)
} else {
if err := up.AssertNoFileConflict(ctx, folderID, path); err != nil {
return nil, err
}
fe, err = up.UploadToFolder(ctx, folderID, path)
}
if err != nil {
return nil, fmt.Errorf("dav upload: %s: %w", path, err)
}
results = append(results, uploadedFile{
Entry: onlyoffice.FileEntryToEntry(fe, onlyoffice.ProviderDAV),
Replaced: deleted,
})
}
return results, nil
}
// ensurePath resolves-or-creates every "/"-separated segment under rootID
// through store. Existing folders are reused by title (case-insensitive), so
// repeated calls return the same id without creating duplicates.
func ensurePath(ctx context.Context, store onlyoffice.FileStore, rootID, path string) (onlyoffice.Entry, error) {
segments, err := splitDavPath(path)
if err != nil {
return onlyoffice.Entry{}, err
}
parent := rootID
var current onlyoffice.Entry
for _, name := range segments {
entries, err := store.List(ctx, parent)
if err != nil {
return onlyoffice.Entry{}, fmt.Errorf("dav ensure-path: list %s: %w", parent, err)
}
found := false
for _, e := range entries {
if e.Kind == onlyoffice.Folder && strings.EqualFold(strings.TrimSpace(e.Title), name) {
current, found = e, true
break
}
}
if !found {
current, err = store.CreateFolder(ctx, parent, name)
if err != nil {
return onlyoffice.Entry{}, fmt.Errorf("dav ensure-path: mkdir %s/%s: %w", parent, name, err)
}
}
parent = current.ID
}
return current, nil
}
// splitDavPath normalizes a Documents path into non-empty segments. "." is
// ignored; ".." is rejected rather than creating a literal folder named "..".
func splitDavPath(path string) ([]string, error) {
var segments []string
for _, s := range strings.Split(path, "/") {
s = strings.TrimSpace(s)
switch s {
case "", ".":
continue
case "..":
return nil, fmt.Errorf("dav ensure-path: %q not allowed", s)
}
segments = append(segments, s)
}
if len(segments) == 0 {
return nil, fmt.Errorf("dav ensure-path: empty path")
}
return segments, nil
}
// myDocumentsID resolves the concrete id of the "My documents" section so
// ensure-path can create folders under it (the symbolic "@my" is list-only on
// some portals). rootFolderType 5 = My; the title match covers portals that
// omit rootFolderType.
func myDocumentsID(ctx context.Context, c *onlyoffice.Client) (string, error) {
sections, err := c.ListDavSections(ctx)
if err != nil {
return "", err
}
for _, s := range sections {
if s.RootType == 5 && s.ID != "" {
return s.ID, nil
}
}
for _, s := range sections {
if strings.EqualFold(strings.TrimSpace(s.Title), "My documents") && s.ID != "" {
return s.ID, nil
}
}
return "", fmt.Errorf("dav: no My documents section in @root; pass --under FOLDER_ID")
}
func davRemoveCmd() *cobra.Command { func davRemoveCmd() *cobra.Command {
var folderIDs []string var folderIDs []string
cmd := &cobra.Command{ cmd := &cobra.Command{
+121
View File
@@ -0,0 +1,121 @@
//go:build integration
package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
)
// TestIntegrationDavEnsurePathUpload exercises the dav ensure-path/upload
// helpers against a live instance: idempotent folder creation, upload, replace
// and no-replace conflict. Destructive (creates and removes a throwaway
// folder); skips cleanly without credentials. Only run against instances you
// own: go test -tags=integration ./cmd/oo -run TestIntegrationDavEnsurePathUpload
func TestIntegrationDavEnsurePathUpload(t *testing.T) {
ctx := context.Background()
c, err := bootstrap.NewClient(ctx)
if err != nil {
t.Skipf("no live OnlyOffice credentials: %v", err)
}
root, err := myDocumentsID(ctx, c)
if err != nil {
t.Skipf("My documents root unavailable: %v", err)
}
store := c.FileStore(onlyoffice.ProviderDAV)
suffix := time.Now().UTC().Format("20060102-150405")
base := "oo-it-" + suffix
first, err := ensurePath(ctx, store, root, base+"/nested")
if err != nil {
t.Fatalf("ensurePath: %v", err)
}
baseID := findFolderID(t, ctx, store, root, base)
t.Cleanup(func() { _ = store.Delete(context.Background(), []string{baseID}) })
second, err := ensurePath(ctx, store, root, base+"/nested")
if err != nil {
t.Fatalf("ensurePath (second): %v", err)
}
if second.ID != first.ID {
t.Fatalf("ensure-path not idempotent: %s != %s", second.ID, first.ID)
}
if n := countFolders(t, ctx, store, baseID, "nested"); n != 1 {
t.Fatalf("nested folder duplicated: %d children named nested, want 1", n)
}
local := filepath.Join(t.TempDir(), "oo-it-"+suffix+".xlsx")
if err := os.WriteFile(local, []byte("integration dav upload "+suffix+"\n"), 0o600); err != nil {
t.Fatal(err)
}
name := filepath.Base(local)
if _, err := uploadLocal(ctx, c, first.ID, []string{local}, true); err != nil {
t.Fatalf("uploadLocal: %v", err)
}
if n := countFiles(t, ctx, store, first.ID, name); n != 1 {
t.Fatalf("after upload: %d files named %s, want 1", n, name)
}
if _, err := uploadLocal(ctx, c, first.ID, []string{local}, true); err != nil {
t.Fatalf("uploadLocal (replace): %v", err)
}
if n := countFiles(t, ctx, store, first.ID, name); n != 1 {
t.Fatalf("after replace: %d files named %s, want 1", n, name)
}
if _, err := uploadLocal(ctx, c, first.ID, []string{local}, false); !errors.Is(err, onlyoffice.ErrFileExists) {
t.Fatalf("no-replace conflict err = %v, want ErrFileExists", err)
}
}
func findFolderID(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent, title string) string {
t.Helper()
for _, e := range mustList(t, ctx, store, parent) {
if e.Kind == onlyoffice.Folder && strings.EqualFold(e.Title, title) {
return e.ID
}
}
t.Fatalf("folder %q not found under %s", title, parent)
return ""
}
func countFolders(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent, title string) int {
t.Helper()
n := 0
for _, e := range mustList(t, ctx, store, parent) {
if e.Kind == onlyoffice.Folder && strings.EqualFold(e.Title, title) {
n++
}
}
return n
}
func countFiles(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent, title string) int {
t.Helper()
n := 0
for _, e := range mustList(t, ctx, store, parent) {
if e.Kind == onlyoffice.File && e.Title == title {
n++
}
}
return n
}
func mustList(t *testing.T, ctx context.Context, store onlyoffice.FileStore, parent string) []onlyoffice.Entry {
t.Helper()
entries, err := store.List(ctx, parent)
if err != nil {
t.Fatalf("List(%s): %v", parent, err)
}
return entries
}
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
onlyoffice "github.com/eslider/go-onlyoffice"
)
// fakeFolderStore is an in-memory onlyoffice.FileStore for the ensure-path
// logic. It records created folders so idempotency can be asserted.
type fakeFolderStore struct {
nextID int
entries map[string][]onlyoffice.Entry
created []string
}
func newFakeFolderStore() *fakeFolderStore {
return &fakeFolderStore{entries: map[string][]onlyoffice.Entry{}}
}
func (f *fakeFolderStore) Name() string { return "fake" }
func (f *fakeFolderStore) List(_ context.Context, parent string) ([]onlyoffice.Entry, error) {
return append([]onlyoffice.Entry(nil), f.entries[parent]...), nil
}
func (f *fakeFolderStore) CreateFolder(_ context.Context, parent, title string) (onlyoffice.Entry, error) {
f.nextID++
e := onlyoffice.Entry{
ID: fmt.Sprintf("id-%d", f.nextID),
ParentID: parent,
Title: title,
Kind: onlyoffice.Folder,
}
f.entries[parent] = append(f.entries[parent], e)
f.created = append(f.created, parent+"/"+title)
return e, nil
}
func (f *fakeFolderStore) Stat(context.Context, string) (onlyoffice.Entry, error) {
return onlyoffice.Entry{}, errors.New("not implemented")
}
func (f *fakeFolderStore) Upload(context.Context, string, string, io.Reader) (onlyoffice.Entry, error) {
return onlyoffice.Entry{}, errors.New("not implemented")
}
func (f *fakeFolderStore) Download(context.Context, string, io.Writer) (int64, error) {
return 0, errors.New("not implemented")
}
func (f *fakeFolderStore) Move(context.Context, []string, string) error {
return errors.New("not implemented")
}
func (f *fakeFolderStore) Copy(context.Context, []string, string) error {
return errors.New("not implemented")
}
func (f *fakeFolderStore) Rename(context.Context, string, string) error {
return errors.New("not implemented")
}
func (f *fakeFolderStore) Delete(context.Context, []string) error {
return errors.New("not implemented")
}
// fakeUploader is the *onlyoffice.Client slice dav upload depends on.
type fakeUploader struct {
conflict bool
uploads []string
replaced []string
}
func (f *fakeUploader) UploadToFolderReplacing(_ context.Context, folderID, localPath string) (*onlyoffice.FileEntry, []int, error) {
f.uploads = append(f.uploads, localPath)
ids := []int(nil)
if f.conflict {
ids = []int{7}
f.replaced = append(f.replaced, folderID+"#7")
}
return fakeFileEntry(99, localPath, folderID), ids, nil
}
func (f *fakeUploader) AssertNoFileConflict(_ context.Context, folderID, localPath string) error {
if f.conflict {
return fmt.Errorf("%w: conflict in folder %s for %s", onlyoffice.ErrFileExists, folderID, localPath)
}
return nil
}
func (f *fakeUploader) UploadToFolder(_ context.Context, folderID, localPath string) (*onlyoffice.FileEntry, error) {
f.uploads = append(f.uploads, localPath)
return fakeFileEntry(99, localPath, folderID), nil
}
func fakeFileEntry(id int, localPath, folderID string) *onlyoffice.FileEntry {
num := json.Number(fmt.Sprintf("%d", id))
title := localPath[strings.LastIndex(localPath, "/")+1:]
return &onlyoffice.FileEntry{
ID: &num,
Title: &title,
FolderID: &num,
}
}
func TestEnsurePathCreatesNestedFolders(t *testing.T) {
store := newFakeFolderStore()
ctx := context.Background()
got, err := ensurePath(ctx, store, "root", "Banks/Caixa")
if err != nil {
t.Fatalf("ensurePath: %v", err)
}
if got.Kind != onlyoffice.Folder || got.ID == "" {
t.Fatalf("ensurePath returned %+v, want a folder with an id", got)
}
if want := []string{"root/Banks", "id-1/Caixa"}; !equalStrings(store.created, want) {
t.Fatalf("created %v, want %v", store.created, want)
}
}
func TestEnsurePathIsIdempotent(t *testing.T) {
store := newFakeFolderStore()
ctx := context.Background()
first, err := ensurePath(ctx, store, "root", "Banks/Caixa")
if err != nil {
t.Fatalf("ensurePath first: %v", err)
}
created := len(store.created)
second, err := ensurePath(ctx, store, "root", "Banks/Caixa")
if err != nil {
t.Fatalf("ensurePath second: %v", err)
}
if second.ID != first.ID {
t.Fatalf("second run id = %q, want %q (no duplicate)", second.ID, first.ID)
}
if len(store.created) != created {
t.Fatalf("second run created folders: %v", store.created)
}
}
func TestEnsurePathReusesExistingFolder(t *testing.T) {
store := newFakeFolderStore()
store.entries["root"] = []onlyoffice.Entry{
{ID: "banks-id", Title: "Banks", Kind: onlyoffice.Folder},
}
store.entries["banks-id"] = []onlyoffice.Entry{
{ID: "caixa-id", Title: "Caixa", Kind: onlyoffice.Folder},
}
got, err := ensurePath(context.Background(), store, "root", "Banks/Caixa")
if err != nil {
t.Fatalf("ensurePath: %v", err)
}
if got.ID != "caixa-id" {
t.Fatalf("id = %q, want caixa-id", got.ID)
}
if len(store.created) != 0 {
t.Fatalf("created %v, want none", store.created)
}
}
func TestEnsurePathRejectsEmptyAndDotDot(t *testing.T) {
store := newFakeFolderStore()
for _, path := range []string{"", "/", "Banks/../Caixa"} {
if _, err := ensurePath(context.Background(), store, "root", path); err == nil {
t.Fatalf("ensurePath(%q) = nil error, want failure", path)
}
}
}
func TestUploadLocalReplacesByDefault(t *testing.T) {
up := &fakeUploader{conflict: true}
results, err := uploadLocal(context.Background(), up, "folder-1", []string{"a/f.xlsx"}, true)
if err != nil {
t.Fatalf("uploadLocal: %v", err)
}
if len(results) != 1 || results[0].Entry.Title != "f.xlsx" {
t.Fatalf("results = %+v", results)
}
if len(results[0].Replaced) != 1 || results[0].Replaced[0] != 7 {
t.Fatalf("replaced = %v, want [7]", results[0].Replaced)
}
if len(up.replaced) != 1 {
t.Fatalf("UploadToFolderReplacing not used for replace: %v", up.replaced)
}
}
func TestUploadLocalNoReplaceFailsOnConflict(t *testing.T) {
up := &fakeUploader{conflict: true}
_, err := uploadLocal(context.Background(), up, "folder-1", []string{"a/f.xlsx"}, false)
if !errors.Is(err, onlyoffice.ErrFileExists) {
t.Fatalf("err = %v, want ErrFileExists", err)
}
if len(up.uploads) != 0 {
t.Fatalf("uploaded despite conflict: %v", up.uploads)
}
}
func TestUploadLocalNoReplaceUploadsWhenFree(t *testing.T) {
up := &fakeUploader{}
results, err := uploadLocal(context.Background(), up, "folder-1", []string{"a/f.xlsx", "a/g.pdf"}, false)
if err != nil {
t.Fatalf("uploadLocal: %v", err)
}
if len(results) != 2 {
t.Fatalf("results = %+v", results)
}
if len(up.uploads) != 2 {
t.Fatalf("uploads = %v", up.uploads)
}
}
func TestDavRegistersUploadAndEnsurePath(t *testing.T) {
for _, name := range []string{"ensure-path", "upload"} {
cmd, _, err := rootCmd.Find([]string{"dav", name})
if err != nil {
t.Fatalf("dav %s not registered: %v", name, err)
}
if cmd.Name() != name {
t.Fatalf("resolved %q, want %q", cmd.Name(), name)
}
}
upload, _, err := rootCmd.Find([]string{"dav", "upload"})
if err != nil {
t.Fatal(err)
}
flag := upload.Flags().Lookup("replace")
if flag == nil || flag.DefValue != "true" {
t.Fatalf("upload --replace flag = %+v, want default true", flag)
}
ensure, _, err := rootCmd.Find([]string{"dav", "ensure-path"})
if err != nil {
t.Fatal(err)
}
if ensure.Flags().Lookup("under") == nil {
t.Fatal("ensure-path missing --under flag")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+1 -1
View File
@@ -18,7 +18,7 @@
// 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 | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx // oo docs tools | convert | pdf | presigned | csv | json | optimize | ocr | hocr | as-md | put-md | put-txt | put-xlsx
// oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird // oo catalog match | merge | apply | scan-contacts | scan-projects | scan-thunderbird
// oo dav ls | move | copy | mkdir | rename-file | rename-folder | download | fileops // oo dav ls | move | copy | mkdir | ensure-path | upload | rename-file | rename-folder | download | fileops
// oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json] // oo search QUERY [--content] [--folder ID] [--limit N] [--backend oo|own] [--json]
// oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run] // oo index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run]
// //