From 55356558a15f2dae79c972c0a84028343c884212 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 25 Sep 2026 15:07:57 +0100 Subject: [PATCH] feat(dav): oo dav upload (local file) + ensure-path (mkdir -p) in Documents (#80) --- AGENTS.md | 2 +- README.md | 11 +- cmd/oo/dav.go | 189 ++++++++++++++++++++++++ cmd/oo/dav_integration_test.go | 121 +++++++++++++++ cmd/oo/dav_test.go | 260 +++++++++++++++++++++++++++++++++ cmd/oo/main.go | 2 +- 6 files changed, 582 insertions(+), 3 deletions(-) create mode 100644 cmd/oo/dav_integration_test.go create mode 100644 cmd/oo/dav_test.go diff --git a/AGENTS.md b/AGENTS.md index c8e3e9e..1eb0610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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): - `main.go` — entry point (docstring lists the command tree). - `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. - **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. diff --git a/README.md b/README.md index ab5e7a2..2cfc62b 100644 --- a/README.md +++ b/README.md @@ -741,6 +741,10 @@ surfaces such per-operation errors instead of a silent nil oo dav ls 659 oo dav ls @root # virtual sections (Documents, Projects, …) 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 --folders 670 # move folders along with files oo dav copy 659 22881 @@ -750,6 +754,11 @@ oo dav download 22881 --to ./copy.pdf # default path: ./ 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`) 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` | | `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` | -| `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`) | | `index` | `folder FOLDER_ID`, `files FILE_ID...` (`--recursive`, `--exts pdf`, `--limit N`, `--dry-run`) | diff --git a/cmd/oo/dav.go b/cmd/oo/dav.go index d51cd70..690d2de 100644 --- a/cmd/oo/dav.go +++ b/cmd/oo/dav.go @@ -1,8 +1,10 @@ package main import ( + "context" "fmt" "os" + "strings" "time" onlyoffice "github.com/eslider/go-onlyoffice" @@ -26,6 +28,8 @@ func davCmd() *cobra.Command { cmd.AddCommand(davMoveCmd()) cmd.AddCommand(davCopyCmd()) cmd.AddCommand(davMkdirCmd()) + cmd.AddCommand(davEnsurePathCmd()) + cmd.AddCommand(davUploadCmd()) cmd.AddCommand(davRemoveCmd()) cmd.AddCommand(davRenameFileCmd()) 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 { var folderIDs []string cmd := &cobra.Command{ diff --git a/cmd/oo/dav_integration_test.go b/cmd/oo/dav_integration_test.go new file mode 100644 index 0000000..800833b --- /dev/null +++ b/cmd/oo/dav_integration_test.go @@ -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 +} diff --git a/cmd/oo/dav_test.go b/cmd/oo/dav_test.go new file mode 100644 index 0000000..75f125f --- /dev/null +++ b/cmd/oo/dav_test.go @@ -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 +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 552c942..9ae5e60 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -18,7 +18,7 @@ // 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 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 index folder FOLDER_ID | files FILE_ID... [--recursive] [--exts pdf] [--dry-run] //