feat(dav): oo dav upload (local file) + ensure-path (mkdir -p) in Documents (#80)
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 5s
Tests / Test (Go stable) (pull_request) Successful in 1m9s
Tests / Test (Go 1.25) (pull_request) Successful in 1m11s

This commit is contained in:
2026-09-25 15:07:57 +01:00
parent aa069bea56
commit 55356558a1
6 changed files with 582 additions and 3 deletions
+189
View File
@@ -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{