Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ def456 4.5 MiB 1 month ago /Photos/family.png

#### Time format

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

```sh
$ dbxcli ls -l --time-format=short /Photos
Expand Down Expand Up @@ -211,7 +211,7 @@ $ dbxcli ls -l --sort=type /Documents # folders, files, deleted
$ dbxcli search -l --time-format=short --sort=size "report"
```

All `--sort`, `--reverse`, `--time`, and `--time-format` flags work with both `ls` and `search`.
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`.

### Team management

Expand Down
8 changes: 8 additions & 0 deletions cmd/mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ type mockFilesClient struct {
getMetadataFn func(arg *files.GetMetadataArg) (files.IsMetadata, error)
listFolderFn func(arg *files.ListFolderArg) (*files.ListFolderResult, error)
listFolderContinueFn func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error)
listRevisionsFn func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error)
moveV2Fn func(arg *files.RelocationArg) (*files.RelocationResult, error)
permanentlyDeleteFn func(arg *files.DeleteArg) error
restoreFn func(arg *files.RestoreArg) (*files.FileMetadata, error)
searchV2Fn func(arg *files.SearchV2Arg) (*files.SearchV2Result, error)
searchContinueV2Fn func(arg *files.SearchV2ContinueArg) (*files.SearchV2Result, error)
}
Expand Down Expand Up @@ -175,6 +177,9 @@ func (m *mockFilesClient) ListFolderLongpoll(arg *files.ListFolderLongpollArg) (
return nil, nil
}
func (m *mockFilesClient) ListRevisions(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) {
if m.listRevisionsFn != nil {
return m.listRevisionsFn(arg)
}
return nil, nil
}
func (m *mockFilesClient) LockFileBatch(arg *files.LockFileBatchArg) (*files.LockFileBatchResult, error) {
Expand Down Expand Up @@ -232,6 +237,9 @@ func (m *mockFilesClient) PropertiesUpdate(arg *file_properties.UpdateProperties
return nil
}
func (m *mockFilesClient) Restore(arg *files.RestoreArg) (*files.FileMetadata, error) {
if m.restoreFn != nil {
return m.restoreFn(arg)
}
return nil, nil
}
func (m *mockFilesClient) SaveUrl(arg *files.SaveUrlArg) (*files.SaveUrlResult, error) {
Expand Down
89 changes: 83 additions & 6 deletions cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,35 @@ package cmd

import (
"errors"
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
"github.com/spf13/cobra"
)

type restoreInput struct {
Path string `json:"path"`
Revision string `json:"revision"`
}

type restoreMetadata struct {
Type string `json:"type"`
PathDisplay string `json:"path_display,omitempty"`
ID string `json:"id,omitempty"`
Rev string `json:"rev,omitempty"`
Size uint64 `json:"size,omitempty"`
ClientModified time.Time `json:"client_modified"`
ServerModified time.Time `json:"server_modified"`
}

type restoreResult struct {
Input restoreInput `json:"input"`
Result restoreMetadata `json:"result"`
}

func restore(cmd *cobra.Command, args []string) (err error) {
if len(args) != 2 {
return errors.New("`restore` requires `file` and `revision` arguments")
return errors.New("`restore` requires `target-path` and `revision` arguments")
}

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

arg := files.NewRestoreArg(path, rev)

dbx := files.New(config)
if _, err = dbx.Restore(arg); err != nil {
dbx := filesNewFunc(config)
metadata, err := dbx.Restore(arg)
if err != nil {
return
}

verbose, _ := cmd.Flags().GetBool("verbose")
if verbose {
printRestoreResult(cmd, newRestoreResult(path, rev, metadata))
}

return
}

func newRestoreResult(path, revision string, metadata *files.FileMetadata) restoreResult {
return restoreResult{
Input: restoreInput{
Path: path,
Revision: revision,
},
Result: restoreMetadataFromDropbox(path, metadata),
}
}

func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) restoreMetadata {
if metadata == nil {
return restoreMetadata{
Type: "file",
PathDisplay: path,
}
}
return restoreMetadata{
Type: "file",
PathDisplay: metadataDisplayPath(path, metadata.PathDisplay),
ID: metadata.Id,
Rev: metadata.Rev,
Size: metadata.Size,
ClientModified: metadata.ClientModified,
ServerModified: metadata.ServerModified,
}
}

func printRestoreResult(cmd *cobra.Command, result restoreResult) {
path := result.Result.PathDisplay
if path == "" {
path = result.Input.Path
}

if result.Result.Rev != "" && result.Result.Rev != result.Input.Revision {
commandOutput(cmd).Info("Restored %s to revision %s (current revision %s, server modified %s)",
path, result.Input.Revision, result.Result.Rev, result.Result.ServerModified.Format(time.RFC3339))
return
}

commandOutput(cmd).Info("Restored %s to revision %s (server modified %s)",
path, result.Input.Revision, result.Result.ServerModified.Format(time.RFC3339))
}

// restoreCmd represents the restore command
var restoreCmd = &cobra.Command{
Use: "restore [flags] <target> <revision>",
Short: "Restore files",
RunE: restore,
Use: "restore [flags] <target-path> <revision>",
Short: "Restore a file revision",
Long: `Restore a Dropbox file at <target-path> to the supplied revision.

The target path is the Dropbox path where the restored file is saved.
Use "dbxcli revs <target-path>" to list available revisions.`,
Example: ` dbxcli revs /Reports/old.pdf
dbxcli restore /Reports/old.pdf 015f...`,
RunE: restore,
}

func init() {
Expand Down
132 changes: 132 additions & 0 deletions cmd/restore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package cmd

import (
"bytes"
"strings"
"testing"
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
"github.com/spf13/cobra"
)

func TestRestoreArgValidation(t *testing.T) {
err := restore(restoreCmd, []string{})
if err == nil {
t.Fatal("expected error for missing args")
}
for _, want := range []string{"target-path", "revision"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want mention of %q", err.Error(), want)
}
}
}

func TestRestoreHelpClarifiesTargetPath(t *testing.T) {
if !strings.Contains(restoreCmd.Use, "<target-path> <revision>") {
t.Fatalf("Use = %q, want target-path and revision", restoreCmd.Use)
}

for _, want := range []string{
"where the restored file is saved",
"dbxcli revs <target-path>",
} {
if !strings.Contains(restoreCmd.Long, want) {
t.Fatalf("Long = %q, want mention of %q", restoreCmd.Long, want)
}
}
}

func TestRestoreQuietByDefault(t *testing.T) {
cmd, stdout := testRestoreCmd()
var restoreArg *files.RestoreArg
serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
restoreArg = arg
return &files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
Rev: "current-rev",
ServerModified: serverModified,
}, nil
},
}
stubFilesClient(t, mock)

if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
t.Fatalf("restore error: %v", err)
}
if restoreArg == nil {
t.Fatal("Restore was not called")
}
if restoreArg.Path != "/Reports/old.pdf" || restoreArg.Rev != "target-rev" {
t.Fatalf("restore arg = %#v, want path /Reports/old.pdf and rev target-rev", restoreArg)
}
if got := stdout.String(); got != "" {
t.Fatalf("stdout = %q, want quiet success", got)
}
}

func TestRestoreVerbosePrintsRevisionAndServerModifiedTime(t *testing.T) {
cmd, stdout := testRestoreCmd()
if err := cmd.Flags().Set("verbose", "true"); err != nil {
t.Fatalf("set verbose: %v", err)
}

serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
return &files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
Rev: "current-rev",
ServerModified: serverModified,
}, nil
},
}
stubFilesClient(t, mock)

if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
t.Fatalf("restore error: %v", err)
}

want := "Restored /Reports/old.pdf to revision target-rev (current revision current-rev, server modified 2026-06-17T12:30:00Z)\n"
if got := stdout.String(); got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}

func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) {
clientModified := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC)
serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
result := newRestoreResult("/Reports/old.pdf", "target-rev", &files.FileMetadata{
Metadata: files.Metadata{
PathDisplay: "/Reports/old.pdf",
},
Id: "id:abc",
Rev: "current-rev",
Size: 123,
ClientModified: clientModified,
ServerModified: serverModified,
})

if result.Input.Path != "/Reports/old.pdf" || result.Input.Revision != "target-rev" {
t.Fatalf("input = %#v, want path and target revision", result.Input)
}
if result.Result.Type != "file" || result.Result.PathDisplay != "/Reports/old.pdf" {
t.Fatalf("metadata = %#v, want file path metadata", result.Result)
}
if result.Result.ID != "id:abc" || result.Result.Rev != "current-rev" || result.Result.Size != 123 {
t.Fatalf("metadata = %#v, want id, current rev, and size", result.Result)
}
if !result.Result.ClientModified.Equal(clientModified) || !result.Result.ServerModified.Equal(serverModified) {
t.Fatalf("metadata times = %#v, want client and server modified times", result.Result)
}
}

func testRestoreCmd() (*cobra.Command, *bytes.Buffer) {
var stdout bytes.Buffer
cmd := &cobra.Command{Use: "restore"}
cmd.SetOut(&stdout)
cmd.Flags().BoolP("verbose", "v", false, "")
return cmd, &stdout
}
33 changes: 23 additions & 10 deletions cmd/revs.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ package cmd
import (
"errors"
"fmt"
"os"
"io"
"text/tabwriter"

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

arg := files.NewListRevisionsArg(path)

dbx := files.New(config)
dbx := filesNewFunc(config)
res, err := dbx.ListRevisions(arg)
if err != nil {
return
}

long, _ := cmd.Flags().GetBool("long")
opts := parseLsOptions(cmd)

if long {
fmt.Printf("Revision\tSize\tLast modified\tPath\n")
return commandOutput(cmd).RenderText(func(w io.Writer) error {
return renderRevisionResults(w, res.Entries, opts)
})
}

func renderRevisionResults(out io.Writer, entries []*files.FileMetadata, opts listOptions) error {
w := new(tabwriter.Writer)
w.Init(out, 4, 8, 1, ' ', 0)

if opts.long {
_, _ = fmt.Fprint(w, "Revision\tSize\tLast modified\tPath\n")
}

for _, e := range res.Entries {
if long {
printFileMetadata(os.Stdout, e, long)
for _, entry := range entries {
if opts.long {
_, _ = fmt.Fprint(w, formatFileMetadataWithOpts(entry, opts))
_, _ = fmt.Fprintln(w)
} else {
fmt.Printf("%s\n", e.Rev)
_, _ = fmt.Fprintln(w, entry.Rev)
}
}

return
return w.Flush()
}

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

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