Skip to content

Commit 1e59484

Browse files
Merge pull request #359 from AndreyVMarkelov/revisions_get
Support Dropbox references in get
2 parents 4b5429b + 9f7e64f commit 1e59484

10 files changed

Lines changed: 417 additions & 61 deletions

File tree

cmd/dropbox_reference.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright © 2026 Dropbox, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import "strings"
18+
19+
type dropboxReferenceKind uint8
20+
21+
const (
22+
dropboxPathReference dropboxReferenceKind = iota
23+
dropboxIDReference
24+
dropboxRevisionReference
25+
dropboxNamespaceReference
26+
)
27+
28+
// dropboxReference preserves Dropbox API references as opaque strings. Only
29+
// ordinary paths are normalized; Dropbox remains responsible for validating
30+
// identifier, revision, and namespace reference values.
31+
type dropboxReference struct {
32+
value string
33+
kind dropboxReferenceKind
34+
}
35+
36+
func newDropboxReference(value string) dropboxReference {
37+
switch {
38+
case strings.HasPrefix(value, "id:"):
39+
return dropboxReference{value: value, kind: dropboxIDReference}
40+
case strings.HasPrefix(value, "rev:"):
41+
return dropboxReference{value: value, kind: dropboxRevisionReference}
42+
case strings.HasPrefix(value, "ns:"):
43+
return dropboxReference{value: value, kind: dropboxNamespaceReference}
44+
default:
45+
if !strings.HasPrefix(value, "/") {
46+
value = "/" + value
47+
}
48+
return dropboxReference{value: cleanDropboxPath(value), kind: dropboxPathReference}
49+
}
50+
}
51+
52+
func (r dropboxReference) String() string {
53+
return r.value
54+
}
55+
56+
func (r dropboxReference) isPath() bool {
57+
return r.kind == dropboxPathReference
58+
}

cmd/dropbox_reference_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright © 2026 Dropbox, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import "testing"
18+
19+
func TestNewDropboxReference(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
input string
23+
want string
24+
kind dropboxReferenceKind
25+
}{
26+
{name: "absolute path", input: "/folder/file.txt", want: "/folder/file.txt", kind: dropboxPathReference},
27+
{name: "relative path", input: "folder/file.txt", want: "/folder/file.txt", kind: dropboxPathReference},
28+
{name: "file id", input: "id:opaque-value", want: "id:opaque-value", kind: dropboxIDReference},
29+
{name: "revision", input: "rev:opaque-value", want: "rev:opaque-value", kind: dropboxRevisionReference},
30+
{name: "namespace path", input: "ns:123/folder/file.txt", want: "ns:123/folder/file.txt", kind: dropboxNamespaceReference},
31+
}
32+
33+
for _, tt := range tests {
34+
t.Run(tt.name, func(t *testing.T) {
35+
got := newDropboxReference(tt.input)
36+
if got.String() != tt.want {
37+
t.Fatalf("String() = %q, want %q", got.String(), tt.want)
38+
}
39+
if got.kind != tt.kind {
40+
t.Fatalf("kind = %d, want %d", got.kind, tt.kind)
41+
}
42+
})
43+
}
44+
}

cmd/format.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,7 @@ func compareLess(a, b files.IsMetadata, opts listOptions) bool {
8080
}
8181

8282
func entryPath(e files.IsMetadata) string {
83-
switch f := e.(type) {
84-
case *files.FileMetadata:
85-
return f.PathDisplay
86-
case *files.FolderMetadata:
87-
return f.PathDisplay
88-
case *files.DeletedMetadata:
89-
return f.PathDisplay
90-
}
91-
return ""
83+
return metadataPathDisplay(e)
9284
}
9385

9486
func entrySize(e files.IsMetadata) uint64 {

cmd/get.go

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,13 @@ func get(cmd *cobra.Command, args []string) (err error) {
6868
return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
6969
}
7070

71-
src, err := validatePath(args[0])
72-
if err != nil {
73-
return
74-
}
71+
srcRef := newDropboxReference(args[0])
72+
src := srcRef.String()
7573

76-
dst := path.Base(src)
74+
dst := ""
75+
if srcRef.isPath() {
76+
dst = path.Base(src)
77+
}
7778
if len(args) == 2 {
7879
dst = args[1]
7980
}
@@ -92,11 +93,15 @@ func get(cmd *cobra.Command, args []string) (err error) {
9293

9394
meta, err := dbx.GetMetadataContext(currentContext(), files.NewGetMetadataArg(src))
9495
if err != nil {
95-
if recursive {
96-
return withJSONErrorDetails(fmt.Errorf("get metadata for %s: %v", src, err), operationErrorDetails("download"), pathErrorDetails(src))
96+
metadataErr := withJSONErrorDetails(fmt.Errorf("get metadata for %s: %v", src, err), operationErrorDetails("download"), pathErrorDetails(src))
97+
if recursive || dst == "" {
98+
return metadataErr
9799
}
98100
// For non-recursive, fall through to download (will fail with proper error)
99101
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
102+
if !srcRef.isPath() {
103+
return metadataErr
104+
}
100105
dst = filepath.Join(dst, path.Base(src))
101106
}
102107
result, err := downloadFileWithResult(dbx, src, dst, opts)
@@ -111,15 +116,26 @@ func get(cmd *cobra.Command, args []string) (err error) {
111116
}, []getResult{result})
112117
}
113118

119+
sourceName := path.Base(src)
120+
if !srcRef.isPath() {
121+
sourceName = metadataName(meta)
122+
if sourceName == "" {
123+
return withJSONErrorDetails(fmt.Errorf("get metadata for %s did not include a name", src), operationErrorDetails("download"), pathErrorDetails(src))
124+
}
125+
if dst == "" {
126+
dst = sourceName
127+
}
128+
}
129+
114130
if _, ok := meta.(*files.FolderMetadata); ok {
115131
if !recursive {
116132
return invalidArgumentsErrorfWithDetails("%s is a folder (use --recursive to download folders)", mergeJSONErrorDetails(operationErrorDetails("download"), pathErrorDetails(src)), src)
117133
}
118134
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
119-
dst = filepath.Join(dst, path.Base(src))
135+
dst = filepath.Join(dst, sourceName)
120136
}
121137
if commandOutputFormat(cmd) == output.FormatText {
122-
return withJSONErrorDetails(getRecursive(dbx, src, dst), operationErrorDetails("download"), pathErrorDetails(src), relocationErrorDetails(src, dst))
138+
return withJSONErrorDetails(getRecursiveWithRootMetadata(dbx, src, dst, meta), operationErrorDetails("download"), pathErrorDetails(src), relocationErrorDetails(src, dst))
123139
}
124140
results, err := getRecursiveWithResults(dbx, src, dst, meta, opts)
125141
if err != nil {
@@ -134,7 +150,7 @@ func get(cmd *cobra.Command, args []string) (err error) {
134150
}
135151

136152
if f, statErr := os.Stat(dst); statErr == nil && f.IsDir() {
137-
dst = filepath.Join(dst, path.Base(src))
153+
dst = filepath.Join(dst, sourceName)
138154
}
139155

140156
result, err := downloadFileWithResult(dbx, src, dst, opts)
@@ -214,32 +230,16 @@ func getStdout(cmd *cobra.Command, src string, recursive bool) error {
214230
return withJSONErrorDetails(downloadToStdout(dbx, src, cmd.OutOrStdout()), operationErrorDetails("download"), pathErrorDetails(src))
215231
}
216232

217-
func getWithClient(dbx filesClient, args []string) (err error) {
218-
if len(args) == 0 || len(args) > 2 {
219-
return invalidArgumentsErrorWithDetails("`get` requires `src` and/or `dst` arguments", argumentsErrorDetails("src", "dst"))
220-
}
221-
222-
src, err := validatePath(args[0])
223-
if err != nil {
224-
return
225-
}
226-
227-
dst := path.Base(src)
228-
if len(args) == 2 {
229-
dst = args[1]
230-
}
231-
if f, err := os.Stat(dst); err == nil && f.IsDir() {
232-
dst = filepath.Join(dst, path.Base(src))
233-
}
234-
235-
return withJSONErrorDetails(downloadFile(dbx, src, dst), operationErrorDetails("download"), pathErrorDetails(src), relocationErrorDetails(src, dst))
236-
}
237-
238233
func getRecursive(dbx filesClient, src, dst string) error {
239234
_, err := getRecursiveInternal(dbx, src, dst, nil, getOptions{}, false)
240235
return err
241236
}
242237

238+
func getRecursiveWithRootMetadata(dbx filesClient, src, dst string, rootMeta files.IsMetadata) error {
239+
_, err := getRecursiveInternal(dbx, src, dst, rootMeta, getOptions{}, false)
240+
return err
241+
}
242+
243243
func getRecursiveWithResults(dbx filesClient, src, dst string, rootMeta files.IsMetadata, opts getOptions) ([]getResult, error) {
244244
return getRecursiveInternal(dbx, src, dst, rootMeta, opts, true)
245245
}
@@ -265,6 +265,10 @@ func getRecursiveInternal(dbx filesClient, src, dst string, rootMeta files.IsMet
265265
}
266266

267267
var results []getResult
268+
rootPath := src
269+
if metadataPath := metadataPathDisplay(rootMeta); metadataPath != "" {
270+
rootPath = metadataPath
271+
}
268272

269273
if collectResults {
270274
result, err := ensureLocalDirectoryResult(src, dst, rootMeta)
@@ -283,7 +287,7 @@ func getRecursiveInternal(dbx filesClient, src, dst string, rootMeta files.IsMet
283287
for _, entry := range entries {
284288
switch f := entry.(type) {
285289
case *files.FolderMetadata:
286-
relPath, err := relativeTo(src, f.PathDisplay)
290+
relPath, err := relativeTo(rootPath, f.PathDisplay)
287291
if err != nil {
288292
downloadErrors = append(downloadErrors, err)
289293
continue
@@ -305,7 +309,7 @@ func getRecursiveInternal(dbx filesClient, src, dst string, rootMeta files.IsMet
305309
}
306310
}
307311
case *files.FileMetadata:
308-
relPath, err := relativeTo(src, f.PathDisplay)
312+
relPath, err := relativeTo(rootPath, f.PathDisplay)
309313
if err != nil {
310314
downloadErrors = append(downloadErrors, err)
311315
continue
@@ -493,11 +497,14 @@ var getCmd = &cobra.Command{
493497
Use: "get [flags] <source> [<target>]",
494498
Short: "Download a file or folder",
495499
Long: `Download a file or folder from Dropbox.
500+
- Source may be a Dropbox path, file ID (id:), revision (rev:), or
501+
namespace-relative path (ns:).
496502
- Use --recursive (-r) to download entire directories.
497503
- Use - as target to write file bytes to stdout.
498504
Stdout is byte-clean: all progress and errors go to stderr.
499505
`,
500506
Example: ` dbxcli get /remote/file.txt ./local-file.txt
507+
dbxcli get rev:a1c10ce0dd78 ./historical-file.txt
501508
dbxcli get -r /remote/folder ./local-folder
502509
dbxcli get /backups/src.tgz - | tar tz
503510
dbxcli get /file.txt - > local-copy.txt`,

0 commit comments

Comments
 (0)