Skip to content

Commit 2b266b4

Browse files
Merge pull request #244 from dropbox/improve-revs-restore
Improve revs and restore commands, clarify restore help
2 parents c5553c4 + 941ec5b commit 2b266b4

6 files changed

Lines changed: 353 additions & 18 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def456 4.5 MiB 1 month ago /Photos/family.png
175175

176176
#### Time format
177177

178-
By default, `ls -l` and `search -l` show relative timestamps ("3 weeks ago"). Use `--time-format` for absolute dates:
178+
By default, `ls -l`, `search -l`, and `revs -l` show relative timestamps ("3 weeks ago"). Use `--time-format` for absolute dates:
179179

180180
```sh
181181
$ dbxcli ls -l --time-format=short /Photos
@@ -211,7 +211,7 @@ $ dbxcli ls -l --sort=type /Documents # folders, files, deleted
211211
$ dbxcli search -l --time-format=short --sort=size "report"
212212
```
213213

214-
All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `ls` and `search`.
214+
All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `ls` and `search`. The `--time` and `--time-format` flags also work with `revs -l`.
215215

216216
### Team management
217217

cmd/mock_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ type mockFilesClient struct {
2020
getMetadataFn func(arg *files.GetMetadataArg) (files.IsMetadata, error)
2121
listFolderFn func(arg *files.ListFolderArg) (*files.ListFolderResult, error)
2222
listFolderContinueFn func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error)
23+
listRevisionsFn func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error)
2324
moveV2Fn func(arg *files.RelocationArg) (*files.RelocationResult, error)
2425
permanentlyDeleteFn func(arg *files.DeleteArg) error
26+
restoreFn func(arg *files.RestoreArg) (*files.FileMetadata, error)
2527
searchV2Fn func(arg *files.SearchV2Arg) (*files.SearchV2Result, error)
2628
searchContinueV2Fn func(arg *files.SearchV2ContinueArg) (*files.SearchV2Result, error)
2729
}
@@ -175,6 +177,9 @@ func (m *mockFilesClient) ListFolderLongpoll(arg *files.ListFolderLongpollArg) (
175177
return nil, nil
176178
}
177179
func (m *mockFilesClient) ListRevisions(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) {
180+
if m.listRevisionsFn != nil {
181+
return m.listRevisionsFn(arg)
182+
}
178183
return nil, nil
179184
}
180185
func (m *mockFilesClient) LockFileBatch(arg *files.LockFileBatchArg) (*files.LockFileBatchResult, error) {
@@ -232,6 +237,9 @@ func (m *mockFilesClient) PropertiesUpdate(arg *file_properties.UpdateProperties
232237
return nil
233238
}
234239
func (m *mockFilesClient) Restore(arg *files.RestoreArg) (*files.FileMetadata, error) {
240+
if m.restoreFn != nil {
241+
return m.restoreFn(arg)
242+
}
235243
return nil, nil
236244
}
237245
func (m *mockFilesClient) SaveUrl(arg *files.SaveUrlArg) (*files.SaveUrlResult, error) {

cmd/restore.go

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,35 @@ package cmd
1616

1717
import (
1818
"errors"
19+
"time"
1920

2021
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
2122
"github.com/spf13/cobra"
2223
)
2324

25+
type restoreInput struct {
26+
Path string `json:"path"`
27+
Revision string `json:"revision"`
28+
}
29+
30+
type restoreMetadata struct {
31+
Type string `json:"type"`
32+
PathDisplay string `json:"path_display,omitempty"`
33+
ID string `json:"id,omitempty"`
34+
Rev string `json:"rev,omitempty"`
35+
Size uint64 `json:"size,omitempty"`
36+
ClientModified time.Time `json:"client_modified"`
37+
ServerModified time.Time `json:"server_modified"`
38+
}
39+
40+
type restoreResult struct {
41+
Input restoreInput `json:"input"`
42+
Result restoreMetadata `json:"result"`
43+
}
44+
2445
func restore(cmd *cobra.Command, args []string) (err error) {
2546
if len(args) != 2 {
26-
return errors.New("`restore` requires `file` and `revision` arguments")
47+
return errors.New("`restore` requires `target-path` and `revision` arguments")
2748
}
2849

2950
path, err := validatePath(args[0])
@@ -35,19 +56,75 @@ func restore(cmd *cobra.Command, args []string) (err error) {
3556

3657
arg := files.NewRestoreArg(path, rev)
3758

38-
dbx := files.New(config)
39-
if _, err = dbx.Restore(arg); err != nil {
59+
dbx := filesNewFunc(config)
60+
metadata, err := dbx.Restore(arg)
61+
if err != nil {
4062
return
4163
}
4264

65+
verbose, _ := cmd.Flags().GetBool("verbose")
66+
if verbose {
67+
printRestoreResult(cmd, newRestoreResult(path, rev, metadata))
68+
}
69+
4370
return
4471
}
4572

73+
func newRestoreResult(path, revision string, metadata *files.FileMetadata) restoreResult {
74+
return restoreResult{
75+
Input: restoreInput{
76+
Path: path,
77+
Revision: revision,
78+
},
79+
Result: restoreMetadataFromDropbox(path, metadata),
80+
}
81+
}
82+
83+
func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) restoreMetadata {
84+
if metadata == nil {
85+
return restoreMetadata{
86+
Type: "file",
87+
PathDisplay: path,
88+
}
89+
}
90+
return restoreMetadata{
91+
Type: "file",
92+
PathDisplay: metadataDisplayPath(path, metadata.PathDisplay),
93+
ID: metadata.Id,
94+
Rev: metadata.Rev,
95+
Size: metadata.Size,
96+
ClientModified: metadata.ClientModified,
97+
ServerModified: metadata.ServerModified,
98+
}
99+
}
100+
101+
func printRestoreResult(cmd *cobra.Command, result restoreResult) {
102+
path := result.Result.PathDisplay
103+
if path == "" {
104+
path = result.Input.Path
105+
}
106+
107+
if result.Result.Rev != "" && result.Result.Rev != result.Input.Revision {
108+
commandOutput(cmd).Info("Restored %s to revision %s (current revision %s, server modified %s)",
109+
path, result.Input.Revision, result.Result.Rev, result.Result.ServerModified.Format(time.RFC3339))
110+
return
111+
}
112+
113+
commandOutput(cmd).Info("Restored %s to revision %s (server modified %s)",
114+
path, result.Input.Revision, result.Result.ServerModified.Format(time.RFC3339))
115+
}
116+
46117
// restoreCmd represents the restore command
47118
var restoreCmd = &cobra.Command{
48-
Use: "restore [flags] <target> <revision>",
49-
Short: "Restore files",
50-
RunE: restore,
119+
Use: "restore [flags] <target-path> <revision>",
120+
Short: "Restore a file revision",
121+
Long: `Restore a Dropbox file at <target-path> to the supplied revision.
122+
123+
The target path is the Dropbox path where the restored file is saved.
124+
Use "dbxcli revs <target-path>" to list available revisions.`,
125+
Example: ` dbxcli revs /Reports/old.pdf
126+
dbxcli restore /Reports/old.pdf 015f...`,
127+
RunE: restore,
51128
}
52129

53130
func init() {

cmd/restore_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func TestRestoreArgValidation(t *testing.T) {
14+
err := restore(restoreCmd, []string{})
15+
if err == nil {
16+
t.Fatal("expected error for missing args")
17+
}
18+
for _, want := range []string{"target-path", "revision"} {
19+
if !strings.Contains(err.Error(), want) {
20+
t.Fatalf("error = %q, want mention of %q", err.Error(), want)
21+
}
22+
}
23+
}
24+
25+
func TestRestoreHelpClarifiesTargetPath(t *testing.T) {
26+
if !strings.Contains(restoreCmd.Use, "<target-path> <revision>") {
27+
t.Fatalf("Use = %q, want target-path and revision", restoreCmd.Use)
28+
}
29+
30+
for _, want := range []string{
31+
"where the restored file is saved",
32+
"dbxcli revs <target-path>",
33+
} {
34+
if !strings.Contains(restoreCmd.Long, want) {
35+
t.Fatalf("Long = %q, want mention of %q", restoreCmd.Long, want)
36+
}
37+
}
38+
}
39+
40+
func TestRestoreQuietByDefault(t *testing.T) {
41+
cmd, stdout := testRestoreCmd()
42+
var restoreArg *files.RestoreArg
43+
serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
44+
mock := &mockFilesClient{
45+
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
46+
restoreArg = arg
47+
return &files.FileMetadata{
48+
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
49+
Rev: "current-rev",
50+
ServerModified: serverModified,
51+
}, nil
52+
},
53+
}
54+
stubFilesClient(t, mock)
55+
56+
if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
57+
t.Fatalf("restore error: %v", err)
58+
}
59+
if restoreArg == nil {
60+
t.Fatal("Restore was not called")
61+
}
62+
if restoreArg.Path != "/Reports/old.pdf" || restoreArg.Rev != "target-rev" {
63+
t.Fatalf("restore arg = %#v, want path /Reports/old.pdf and rev target-rev", restoreArg)
64+
}
65+
if got := stdout.String(); got != "" {
66+
t.Fatalf("stdout = %q, want quiet success", got)
67+
}
68+
}
69+
70+
func TestRestoreVerbosePrintsRevisionAndServerModifiedTime(t *testing.T) {
71+
cmd, stdout := testRestoreCmd()
72+
if err := cmd.Flags().Set("verbose", "true"); err != nil {
73+
t.Fatalf("set verbose: %v", err)
74+
}
75+
76+
serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
77+
mock := &mockFilesClient{
78+
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
79+
return &files.FileMetadata{
80+
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
81+
Rev: "current-rev",
82+
ServerModified: serverModified,
83+
}, nil
84+
},
85+
}
86+
stubFilesClient(t, mock)
87+
88+
if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
89+
t.Fatalf("restore error: %v", err)
90+
}
91+
92+
want := "Restored /Reports/old.pdf to revision target-rev (current revision current-rev, server modified 2026-06-17T12:30:00Z)\n"
93+
if got := stdout.String(); got != want {
94+
t.Fatalf("stdout = %q, want %q", got, want)
95+
}
96+
}
97+
98+
func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) {
99+
clientModified := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC)
100+
serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
101+
result := newRestoreResult("/Reports/old.pdf", "target-rev", &files.FileMetadata{
102+
Metadata: files.Metadata{
103+
PathDisplay: "/Reports/old.pdf",
104+
},
105+
Id: "id:abc",
106+
Rev: "current-rev",
107+
Size: 123,
108+
ClientModified: clientModified,
109+
ServerModified: serverModified,
110+
})
111+
112+
if result.Input.Path != "/Reports/old.pdf" || result.Input.Revision != "target-rev" {
113+
t.Fatalf("input = %#v, want path and target revision", result.Input)
114+
}
115+
if result.Result.Type != "file" || result.Result.PathDisplay != "/Reports/old.pdf" {
116+
t.Fatalf("metadata = %#v, want file path metadata", result.Result)
117+
}
118+
if result.Result.ID != "id:abc" || result.Result.Rev != "current-rev" || result.Result.Size != 123 {
119+
t.Fatalf("metadata = %#v, want id, current rev, and size", result.Result)
120+
}
121+
if !result.Result.ClientModified.Equal(clientModified) || !result.Result.ServerModified.Equal(serverModified) {
122+
t.Fatalf("metadata times = %#v, want client and server modified times", result.Result)
123+
}
124+
}
125+
126+
func testRestoreCmd() (*cobra.Command, *bytes.Buffer) {
127+
var stdout bytes.Buffer
128+
cmd := &cobra.Command{Use: "restore"}
129+
cmd.SetOut(&stdout)
130+
cmd.Flags().BoolP("verbose", "v", false, "")
131+
return cmd, &stdout
132+
}

cmd/revs.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ package cmd
1717
import (
1818
"errors"
1919
"fmt"
20-
"os"
20+
"io"
21+
"text/tabwriter"
2122

2223
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
2324
"github.com/spf13/cobra"
@@ -35,27 +36,37 @@ func revs(cmd *cobra.Command, args []string) (err error) {
3536

3637
arg := files.NewListRevisionsArg(path)
3738

38-
dbx := files.New(config)
39+
dbx := filesNewFunc(config)
3940
res, err := dbx.ListRevisions(arg)
4041
if err != nil {
4142
return
4243
}
4344

44-
long, _ := cmd.Flags().GetBool("long")
45+
opts := parseLsOptions(cmd)
4546

46-
if long {
47-
fmt.Printf("Revision\tSize\tLast modified\tPath\n")
47+
return commandOutput(cmd).RenderText(func(w io.Writer) error {
48+
return renderRevisionResults(w, res.Entries, opts)
49+
})
50+
}
51+
52+
func renderRevisionResults(out io.Writer, entries []*files.FileMetadata, opts listOptions) error {
53+
w := new(tabwriter.Writer)
54+
w.Init(out, 4, 8, 1, ' ', 0)
55+
56+
if opts.long {
57+
_, _ = fmt.Fprint(w, "Revision\tSize\tLast modified\tPath\n")
4858
}
4959

50-
for _, e := range res.Entries {
51-
if long {
52-
printFileMetadata(os.Stdout, e, long)
60+
for _, entry := range entries {
61+
if opts.long {
62+
_, _ = fmt.Fprint(w, formatFileMetadataWithOpts(entry, opts))
63+
_, _ = fmt.Fprintln(w)
5364
} else {
54-
fmt.Printf("%s\n", e.Rev)
65+
_, _ = fmt.Fprintln(w, entry.Rev)
5566
}
5667
}
5768

58-
return
69+
return w.Flush()
5970
}
6071

6172
// revsCmd represents the revs command
@@ -69,4 +80,6 @@ func init() {
6980
RootCmd.AddCommand(revsCmd)
7081

7182
revsCmd.Flags().BoolP("long", "l", false, "Long listing")
83+
revsCmd.Flags().String("time", "server", "Time field: server, client")
84+
revsCmd.Flags().String("time-format", "", "Time format: short (2006-01-02 15:04), rfc3339")
7285
}

0 commit comments

Comments
 (0)