diff --git a/README.md b/README.md index 0d7dc85..6e8374d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd/mock_test.go b/cmd/mock_test.go index f915a25..630a00d 100644 --- a/cmd/mock_test.go +++ b/cmd/mock_test.go @@ -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) } @@ -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) { @@ -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) { diff --git a/cmd/restore.go b/cmd/restore.go index 1cd8d84..57d2abd 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -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]) @@ -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] ", - Short: "Restore files", - RunE: restore, + Use: "restore [flags] ", + Short: "Restore a file revision", + Long: `Restore a Dropbox file at to the supplied revision. + +The target path is the Dropbox path where the restored file is saved. +Use "dbxcli revs " to list available revisions.`, + Example: ` dbxcli revs /Reports/old.pdf + dbxcli restore /Reports/old.pdf 015f...`, + RunE: restore, } func init() { diff --git a/cmd/restore_test.go b/cmd/restore_test.go new file mode 100644 index 0000000..72914ee --- /dev/null +++ b/cmd/restore_test.go @@ -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, " ") { + t.Fatalf("Use = %q, want target-path and revision", restoreCmd.Use) + } + + for _, want := range []string{ + "where the restored file is saved", + "dbxcli revs ", + } { + 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 +} diff --git a/cmd/revs.go b/cmd/revs.go index cf70d1b..a76af59 100644 --- a/cmd/revs.go +++ b/cmd/revs.go @@ -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" @@ -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 @@ -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") } diff --git a/cmd/revs_test.go b/cmd/revs_test.go new file mode 100644 index 0000000..03cb9bb --- /dev/null +++ b/cmd/revs_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/spf13/cobra" +) + +func TestRenderRevisionResultsPrintsRevisionIDs(t *testing.T) { + entries := []*files.FileMetadata{ + {Rev: "rev-a"}, + {Rev: "rev-b"}, + } + + var out bytes.Buffer + if err := renderRevisionResults(&out, entries, listOptions{}); err != nil { + t.Fatalf("renderRevisionResults returned error: %v", err) + } + + if got, want := out.String(), "rev-a\nrev-b\n"; got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestRenderRevisionResultsLongModeUsesTimeOptions(t *testing.T) { + serverModified := time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC) + clientModified := time.Date(2026, 5, 1, 10, 30, 0, 0, time.UTC) + entries := []*files.FileMetadata{ + { + Metadata: files.Metadata{PathDisplay: "/report.pdf"}, + Rev: "rev-a", + Size: 4096, + ServerModified: serverModified, + ClientModified: clientModified, + }, + } + + var out bytes.Buffer + err := renderRevisionResults(&out, entries, listOptions{ + long: true, + timeField: "client", + timeFormat: "rfc3339", + }) + if err != nil { + t.Fatalf("renderRevisionResults returned error: %v", err) + } + + got := out.String() + for _, want := range []string{ + "Revision", + "Size", + "Last modified", + "Path", + "rev-a", + "4.0 KiB", + "2026-05-01T10:30:00Z", + "/report.pdf", + } { + if !strings.Contains(got, want) { + t.Errorf("output = %q, want to contain %q", got, want) + } + } + if strings.Contains(got, "2026-05-01T09:00:00Z") { + t.Errorf("output = %q, should use client-modified time", got) + } +} + +func TestRevsUsesListRevisionsAndCommandOutput(t *testing.T) { + cmd, stdout := testRevsCmd() + var gotPath string + + stubFilesClient(t, &mockFilesClient{ + listRevisionsFn: func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) { + gotPath = arg.Path + return files.NewListRevisionsResult(false, []*files.FileMetadata{ + {Rev: "rev-c"}, + }), nil + }, + }) + + if err := revs(cmd, []string{"/report.pdf"}); err != nil { + t.Fatalf("revs returned error: %v", err) + } + + if gotPath != "/report.pdf" { + t.Fatalf("ListRevisions path = %q, want %q", gotPath, "/report.pdf") + } + if got, want := stdout.String(), "rev-c\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func testRevsCmd() (*cobra.Command, *bytes.Buffer) { + var stdout bytes.Buffer + cmd := &cobra.Command{Use: "revs"} + cmd.SetOut(&stdout) + cmd.Flags().BoolP("long", "l", false, "") + cmd.Flags().String("time", "server", "") + cmd.Flags().String("time-format", "", "") + return cmd, &stdout +}