diff --git a/README.md b/README.md index cb055e6b..d437b37d 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ proxy. * File operations: `ls`, `cp`, `mkdir`, `mv`, `rm`, `put`, and `get` * Recursive upload and download with `put -r` and `get -r` * Pipe-friendly transfers with stdin upload and stdout download -* Conflict control with `put --if-exists overwrite|skip|fail` and `cp`/`mv --if-exists fail|skip` +* Conflict control with `put --if-exists overwrite|skip|autorename|fail` and `cp`/`mv --if-exists fail|skip|autorename` * Shared-link creation, listing, inspection, update, revoke, and download * Search, file revisions, restore, flexible sorting, and time formatting * Chunked uploads for large files and paginated listing for large directories diff --git a/cmd/cp.go b/cmd/cp.go index 294d4c5b..f6af7108 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -59,6 +59,7 @@ func cp(cmd *cobra.Command, args []string) error { cpErrors = append(cpErrors, relocationError) cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(argument, dst)) } else { + arg.Autorename = opts.ifExists == relocationIfExistsAutorename result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts) if err != nil { cpErrors = append(cpErrors, fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)) @@ -97,7 +98,7 @@ func cp(cmd *cobra.Command, args []string) error { cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) continue } - results = append(results, relocationOperationResult(relocationJSONStatusCopied, result)) + results = append(results, relocationOperationResult(relocationSuccessStatus(relocationJSONStatusCopied, arg, result, opts), result)) } } @@ -125,5 +126,5 @@ var cpCmd = &cobra.Command{ func init() { RootCmd.AddCommand(cpCmd) enableStructuredOutput(cpCmd) - cpCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail or skip") + cpCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail, skip, or autorename") } diff --git a/cmd/cp_test.go b/cmd/cp_test.go index ff8aa4c6..919dbb9e 100644 --- a/cmd/cp_test.go +++ b/cmd/cp_test.go @@ -352,6 +352,96 @@ func TestCpIfExistsFailCallsCopy(t *testing.T) { } } +func TestCpIfExistsAutorenameSetsAutorenameFlag(t *testing.T) { + var copied []*files.RelocationArg + stubFilesClient(t, &mockFilesClient{ + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + copied = append(copied, arg) + metadata := relocationTestFileMetadata("/dest/file (1).txt", 1) + return files.NewRelocationResult(metadata), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsAutorename); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + if len(copied) != 1 { + t.Fatalf("copied = %d, want 1", len(copied)) + } + if !copied[0].Autorename { + t.Fatal("CopyV2 arg Autorename = false, want true") + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusAutorenamed { + t.Fatalf("status = %q, want autorenamed", got.Results[0].Status) + } + if got.Results[0].Result.PathDisplay != "/dest/file (1).txt" { + t.Fatalf("path_display = %q, want /dest/file (1).txt", got.Results[0].Result.PathDisplay) + } +} + +func TestCpIfExistsAutorenameNoConflictKeepsCopiedStatus(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsAutorename); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusCopied { + t.Fatalf("status = %q, want copied", got.Results[0].Status) + } +} + +func TestCpIfExistsAutorenameCaseOnlyPathKeepsCopiedStatus(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return files.NewRelocationResult(relocationTestFileMetadata("/Dest/File.txt", 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsAutorename); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusCopied { + t.Fatalf("status = %q, want copied", got.Results[0].Status) + } + if got.Results[0].Result.PathDisplay != "/Dest/File.txt" { + t.Fatalf("path_display = %q, want /Dest/File.txt", got.Results[0].Result.PathDisplay) + } +} + func TestCpIfExistsSkipExistingDestinationDoesNotCopy(t *testing.T) { var copied bool stubFilesClient(t, &mockFilesClient{ diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index ff8b84d7..0c87890d 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -277,12 +277,12 @@ func TestJSONHelpManifestV1MachineFields(t *testing.T) { } assertJSONHelpArg(t, put.Args, "source", true, "local_path", true) assertJSONHelpArg(t, put.Args, "target", false, "dropbox_path", false) - assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "fail"}) + assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "fail", "autorename"}) if !put.StdinStdout.ReadsStdin || put.StdinStdout.WritesBinaryStdout { t.Fatalf("put stdin_stdout = %+v, want stdin only", put.StdinStdout) } assertStringSliceEqual(t, "put warning codes", put.WarningCodes, []string{jsonWarningCodeSkippedSymlink}) - assertStringSliceEqual(t, "put result statuses", put.ResultStatuses, []string{"created", "existing", "skipped", "uploaded"}) + assertStringSliceEqual(t, "put result statuses", put.ResultStatuses, []string{"autorenamed", "created", "existing", "skipped", "uploaded"}) assertStringSliceEqual(t, "put result kinds", put.ResultKinds, []string{"file", "folder"}) assertStringSliceEqual(t, "put scopes", put.DropboxScopes, []string{"files.content.write", "files.metadata.read"}) if put.ScopeAccuracy != commandManifestScopeAccuracyBestEffort { @@ -308,7 +308,7 @@ func TestJSONHelpManifestV1MachineFields(t *testing.T) { } assertJSONHelpInputProperty(t, put.InputSchema, "target", "string", "arg", "target", "dropbox_path") ifExists := assertJSONHelpInputProperty(t, put.InputSchema, "if_exists", "string", "flag", "if-exists", "enum") - assertStringSliceEqual(t, "put input_schema if_exists enum", ifExists.Enum, []string{"fail", "overwrite", "skip"}) + assertStringSliceEqual(t, "put input_schema if_exists enum", ifExists.Enum, []string{"autorename", "fail", "overwrite", "skip"}) if ifExists.Default != "overwrite" { t.Fatalf("put if_exists default = %#v, want overwrite", ifExists.Default) } @@ -337,7 +337,7 @@ func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) { if !jsonHelpArgByName(t, cp.Args, "source").Variadic { t.Fatal("cp source variadic = false, want true") } - assertStringSliceEqual(t, "cp --if-exists enum", jsonHelpFlagByName(t, cp.Flags, "if-exists").EnumValues, []string{"fail", "skip"}) + assertStringSliceEqual(t, "cp --if-exists enum", jsonHelpFlagByName(t, cp.Flags, "if-exists").EnumValues, []string{"fail", "skip", "autorename"}) assertStringSliceEqual(t, "cp input_schema required", cp.InputSchema.Required, []string{"source", "target"}) source := assertJSONHelpInputProperty(t, cp.InputSchema, "source", "array", "arg", "source", "dropbox_path") if source.Items == nil || source.Items.Type != "string" { @@ -348,7 +348,7 @@ func TestJSONHelpManifestV1SelectedCommandMetadata(t *testing.T) { } assertJSONHelpInputProperty(t, cp.InputSchema, "target", "string", "arg", "target", "dropbox_path") cpIfExists := assertJSONHelpInputProperty(t, cp.InputSchema, "if_exists", "string", "flag", "if-exists", "enum") - assertStringSliceEqual(t, "cp input_schema if_exists enum", cpIfExists.Enum, []string{"fail", "skip"}) + assertStringSliceEqual(t, "cp input_schema if_exists enum", cpIfExists.Enum, []string{"autorename", "fail", "skip"}) create := jsonCommandManifestFor(shareLinkCreateCmd) assertStringSliceEqual(t, "share-link create audience enum", jsonHelpFlagByName(t, create.Flags, "audience").EnumValues, []string{"public", "team", "members", "no-one"}) diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index e859c611..dc7bf8ef 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -113,7 +113,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ commandArg("target", true, false, "dropbox_path", "Dropbox destination path"), }, Examples: []jsonCommandExample{{Description: "Copy a Dropbox file", Command: "dbxcli cp /from.txt /to.txt"}}, - Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip"}, ValueKind: "enum"}}, + Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip", "autorename"}, ValueKind: "enum"}}, DropboxScopes: []string{"files.content.write", "files.metadata.read"}, Known: true, }, @@ -174,7 +174,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ commandArg("target", true, false, "dropbox_path", "Dropbox destination path"), }, Examples: []jsonCommandExample{{Description: "Move a Dropbox file", Command: "dbxcli mv /from.txt /to.txt"}}, - Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip"}, ValueKind: "enum"}}, + Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip", "autorename"}, ValueKind: "enum"}}, DropboxScopes: []string{"files.content.write", "files.metadata.read"}, Known: true, }, @@ -190,7 +190,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ Flags: map[string]jsonCommandFlagMetadata{ "chunksize": {ValueKind: "bytes"}, "debug": {ValueKind: "boolean"}, - "if-exists": {EnumValues: []string{"overwrite", "skip", "fail"}, ValueKind: "enum"}, + "if-exists": {EnumValues: []string{"overwrite", "skip", "fail", "autorename"}, ValueKind: "enum"}, "recursive": {ValueKind: "boolean"}, "workers": {ValueKind: "integer"}, }, @@ -341,15 +341,15 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "account": {Statuses: []string{"found"}, Kinds: []string{"account"}}, - "cp": {Statuses: []string{"copied", "skipped"}, Kinds: []string{"deleted", "file", "folder"}}, + "cp": {Statuses: []string{"autorenamed", "copied", "skipped"}, Kinds: []string{"deleted", "file", "folder"}}, "du": {Statuses: []string{"reported"}, Kinds: []string{"space_usage"}}, "get": {Statuses: []string{"created", "downloaded", "existing"}, Kinds: []string{"file", "folder"}}, "help": {Statuses: []string{"described"}, Kinds: []string{"command"}}, "logout": {Statuses: []string{"already_logged_out", "logged_out"}, Kinds: []string{"auth"}, Warnings: []string{jsonWarningCodeTokenRevokeFailed}}, "ls": {Statuses: []string{"listed"}, Kinds: []string{"deleted", "file", "folder"}}, "mkdir": {Statuses: []string{"created", "existing"}, Kinds: []string{"folder"}}, - "mv": {Statuses: []string{"moved", "skipped"}, Kinds: []string{"deleted", "file", "folder"}}, - "put": {Statuses: []string{"created", "existing", "skipped", "uploaded"}, Kinds: []string{"file", "folder"}, Warnings: []string{jsonWarningCodeSkippedSymlink}}, + "mv": {Statuses: []string{"autorenamed", "moved", "skipped"}, Kinds: []string{"deleted", "file", "folder"}}, + "put": {Statuses: []string{"autorenamed", "created", "existing", "skipped", "uploaded"}, Kinds: []string{"file", "folder"}, Warnings: []string{jsonWarningCodeSkippedSymlink}}, "restore": {Statuses: []string{"restored"}, Kinds: []string{"file"}}, "revs": {Statuses: []string{"revision"}, Kinds: []string{"file"}}, "rm": {Statuses: []string{"deleted", "permanently_deleted"}, Kinds: []string{"deleted", "file", "folder"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index c009bf94..94b4ca07 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -841,7 +841,7 @@ func jsonGoldenErrorOutputExamples() map[string]jsonErrorResponse { ), "invalid_arguments": newJSONErrorResponse( jsonErrorExampleCommand("put"), - invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, or fail)", flagValueErrorDetails("if-exists", "replace"), "replace"), + invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, autorename, or fail)", flagValueErrorDetails("if-exists", "replace"), "replace"), ), "not_found": newJSONErrorResponse( jsonErrorExampleCommand("get"), @@ -1086,15 +1086,15 @@ func jsonContractDefinitions() map[string][]string { func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { return map[string]jsonGoldenCommandSchema{ "account": operationSchema("account_input", schemaRef("account_input"), "account", []string{accountJSONStatusFound}, []string{accountKindAccount}, nil), - "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusCopied, relocationJSONStatusSkipped}, metadataKinds(), nil), + "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusAutorenamed, relocationJSONStatusCopied, relocationJSONStatusSkipped}, metadataKinds(), nil), "du": operationSchema("empty", schemaRef("empty"), "du_output", []string{duJSONStatusReported}, []string{duKindSpaceUsage}, nil), "get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil), "help": operationSchema("help_input", schemaRef("empty"), "command_manifest", []string{jsonHelpStatusDescribed}, []string{jsonHelpKindCommand}, nil), "ls": operationSchema("ls_input", schemaRef("empty"), "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil), "logout": operationSchema("empty", schemaRef("empty"), "logout_result", []string{logoutStatusAlreadyLoggedOut, logoutStatusLoggedOut}, []string{logoutKindAuth}, []string{jsonWarningCodeTokenRevokeFailed}), "mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting}, []string{mkdirKindFolder}, nil), - "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved, relocationJSONStatusSkipped}, metadataKinds(), nil), - "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}), + "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusAutorenamed, relocationJSONStatusMoved, relocationJSONStatusSkipped}, metadataKinds(), nil), + "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusAutorenamed, putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}), "restore": operationSchema("restore_input", schemaRef("restore_input"), "metadata", []string{restoreStatusRestored}, []string{restoreKindFile}, nil), "revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file"}, nil), "rm": operationSchema("empty", schemaRef("remove_input"), "metadata", []string{removeJSONStatusDeleted, removeJSONStatusPermanentlyDeleted}, metadataKinds(), nil), diff --git a/cmd/metadata.go b/cmd/metadata.go index a109b11b..05afe2a9 100644 --- a/cmd/metadata.go +++ b/cmd/metadata.go @@ -14,9 +14,25 @@ package cmd +import "strings" + func metadataDisplayPath(inputPath, metadataPath string) string { if metadataPath != "" { return metadataPath } return inputPath } + +func sameDropboxPath(a string, b string) bool { + return strings.EqualFold(cleanDropboxPath(a), cleanDropboxPath(b)) +} + +func sameDropboxMetadataPath(pathDisplay, pathLower, requested string) bool { + if pathLower != "" { + return sameDropboxPath(pathLower, requested) + } + if pathDisplay != "" { + return sameDropboxPath(pathDisplay, requested) + } + return true +} diff --git a/cmd/mv.go b/cmd/mv.go index b9b68831..e7baaff5 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -58,6 +58,7 @@ func mv(cmd *cobra.Command, args []string) error { mvErrors = append(mvErrors, fmt.Errorf("Error validating move for %s to %s: %v", argument, dst, err)) mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(argument, dst)) } else { + arg.Autorename = opts.ifExists == relocationIfExistsAutorename result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts) if err != nil { mvErrors = append(mvErrors, fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err)) @@ -96,7 +97,7 @@ func mv(cmd *cobra.Command, args []string) error { mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath)) continue } - results = append(results, relocationOperationResult(relocationJSONStatusMoved, result)) + results = append(results, relocationOperationResult(relocationSuccessStatus(relocationJSONStatusMoved, arg, result, opts), result)) } } @@ -123,5 +124,5 @@ var mvCmd = &cobra.Command{ func init() { RootCmd.AddCommand(mvCmd) enableStructuredOutput(mvCmd) - mvCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail or skip") + mvCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail, skip, or autorename") } diff --git a/cmd/mv_test.go b/cmd/mv_test.go index 94f8c8c0..422ed2cc 100644 --- a/cmd/mv_test.go +++ b/cmd/mv_test.go @@ -232,6 +232,68 @@ func TestMvIfExistsFailCallsMove(t *testing.T) { } } +func TestMvIfExistsAutorenameSetsAutorenameFlag(t *testing.T) { + var moved []*files.RelocationArg + stubFilesClient(t, &mockFilesClient{ + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + moved = append(moved, arg) + return files.NewRelocationResult(relocationTestFileMetadata("/dest/file (1).txt", 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsAutorename); err != nil { + t.Fatal(err) + } + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("mv error: %v", err) + } + if len(moved) != 1 { + t.Fatalf("moved = %d, want 1", len(moved)) + } + if !moved[0].Autorename { + t.Fatal("MoveV2 arg Autorename = false, want true") + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusAutorenamed { + t.Fatalf("status = %q, want autorenamed", got.Results[0].Status) + } + if got.Results[0].Result.PathDisplay != "/dest/file (1).txt" { + t.Fatalf("path_display = %q, want /dest/file (1).txt", got.Results[0].Result.PathDisplay) + } +} + +func TestMvIfExistsAutorenameCaseOnlyPathKeepsMovedStatus(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return files.NewRelocationResult(relocationTestFileMetadata("/Dest/File.txt", 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsAutorename); err != nil { + t.Fatal(err) + } + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("mv error: %v", err) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusMoved { + t.Fatalf("status = %q, want moved", got.Results[0].Status) + } + if got.Results[0].Result.PathDisplay != "/Dest/File.txt" { + t.Fatalf("path_display = %q, want /Dest/File.txt", got.Results[0].Result.PathDisplay) + } +} + func TestMvIfExistsSkipExistingDestinationDoesNotMove(t *testing.T) { var moved bool stubFilesClient(t, &mockFilesClient{ diff --git a/cmd/put.go b/cmd/put.go index 27dcba57..904637e7 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -43,9 +43,10 @@ const ( // conservative 128 MiB max that is also a multiple of 4 MiB. putMaxChunkSize int64 = 128 * (1 << 20) - putIfExistsOverwrite = "overwrite" - putIfExistsSkip = "skip" - putIfExistsFail = "fail" + putIfExistsOverwrite = "overwrite" + putIfExistsSkip = "skip" + putIfExistsFail = "fail" + putIfExistsAutorename = "autorename" ) type uploadChunk struct { @@ -208,10 +209,11 @@ type putOptions struct { } const ( - putStatusUploaded = "uploaded" - putStatusSkipped = "skipped" - putStatusCreated = "created" - putStatusExisting = "existing" + putStatusUploaded = "uploaded" + putStatusSkipped = "skipped" + putStatusCreated = "created" + putStatusExisting = "existing" + putStatusAutorenamed = "autorenamed" putKindFile = "file" putKindFolder = "folder" @@ -490,10 +492,10 @@ func normalizePutIfExists(ifExists string) (string, error) { return putIfExistsOverwrite, nil } switch ifExists { - case putIfExistsOverwrite, putIfExistsSkip, putIfExistsFail: + case putIfExistsOverwrite, putIfExistsSkip, putIfExistsFail, putIfExistsAutorename: return ifExists, nil default: - return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, or fail)", flagValueErrorDetails("if-exists", ifExists), ifExists) + return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, autorename, or fail)", flagValueErrorDetails("if-exists", ifExists), ifExists) } } @@ -538,6 +540,7 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { commitInfo := files.NewCommitInfo(dst) commitInfo.Mode.Tag = writeModeForIfExists(ifExists) commitInfo.StrictConflict = ifExists != putIfExistsOverwrite + commitInfo.Autorename = ifExists == putIfExistsAutorename commitInfo.ClientModified = dropboxClientModified(contentsInfo.ModTime()) @@ -550,7 +553,7 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { if err != nil { return putResult{}, err } - return newPutResult(putStatusUploaded, putKindFile, src, dst, metadata) + return newPutResult(putUploadStatus(ifExists, dst, metadata), putKindFile, src, dst, metadata) } uploadArg := &files.UploadArg{CommitInfo: *commitInfo} @@ -562,7 +565,17 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { if err != nil { return putResult{}, err } - return newPutResult(putStatusUploaded, putKindFile, src, dst, metadata) + return newPutResult(putUploadStatus(ifExists, dst, metadata), putKindFile, src, dst, metadata) +} + +// putUploadStatus reports "autorenamed" when --if-exists=autorename caused the +// server to store the file under a different path than requested; otherwise it +// reports the normal "uploaded" status. +func putUploadStatus(ifExists, dst string, metadata *files.FileMetadata) string { + if ifExists == putIfExistsAutorename && metadata != nil && !sameDropboxMetadataPath(metadata.PathDisplay, metadata.PathLower, dst) { + return putStatusAutorenamed + } + return putStatusUploaded } func writeModeForIfExists(ifExists string) string { @@ -580,7 +593,7 @@ func checkPutStdinDestination(dbx filesClient, dst string, ifExists string) (put meta, exists, err := getDestinationMetadata(dbx, dst) if err != nil { - if ifExists == putIfExistsOverwrite { + if ifExists == putIfExistsOverwrite || ifExists == putIfExistsAutorename { return putDestinationUpload, nil, nil } return putDestinationUpload, nil, err @@ -599,7 +612,7 @@ func checkPutDestination(dbx filesClient, dst string, ifExists string) (putDesti if err != nil { return putDestinationUpload, nil, err } - if ifExists == putIfExistsOverwrite { + if ifExists == putIfExistsOverwrite || ifExists == putIfExistsAutorename { return putDestinationUpload, nil, nil } @@ -919,5 +932,5 @@ func init() { putCmd.Flags().IntP("workers", "w", 4, "Number of concurrent upload workers for chunked large-file uploads") putCmd.Flags().Int64P("chunksize", "c", 1<<24, "Chunk size in bytes for chunked large-file uploads; must be a multiple of 4MiB and no more than 128MiB") putCmd.Flags().BoolP("debug", "d", false, "Print debug timing") - putCmd.Flags().String("if-exists", putIfExistsOverwrite, "What to do when the destination file exists: overwrite, skip, or fail") + putCmd.Flags().String("if-exists", putIfExistsOverwrite, "What to do when the destination file exists: overwrite, skip, autorename, or fail") } diff --git a/cmd/put_test.go b/cmd/put_test.go index 9980122c..a7624d29 100644 --- a/cmd/put_test.go +++ b/cmd/put_test.go @@ -709,7 +709,7 @@ func TestPutIfExistsValidation(t *testing.T) { if err == nil { t.Fatal("expected invalid --if-exists error") } - if !bytes.Contains([]byte(err.Error()), []byte("overwrite, skip, or fail")) { + if !bytes.Contains([]byte(err.Error()), []byte("overwrite, skip, autorename, or fail")) { t.Errorf("error = %q, want valid option list", err.Error()) } } @@ -1315,6 +1315,105 @@ func TestPutFileIfExistsOverwriteUsesOverwriteMode(t *testing.T) { } } +func TestPutFileIfExistsAutorenameSetsAutorenameAndReportsRenamed(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + metadataCalls := 0 + var mode string + var autorename bool + mock := &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + metadataCalls++ + return &files.FileMetadata{}, nil + }, + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + mode = arg.Mode.Tag + autorename = arg.Autorename + return &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/existing (1).txt"}}, nil + }, + } + stubFilesClient(t, mock) + + result, err := putFileWithResult(tmpFile, "/existing.txt", putOptions{ + chunkSize: 1 << 24, + workers: 4, + ifExists: putIfExistsAutorename, + }) + if err != nil { + t.Fatalf("putFileWithResult error: %v", err) + } + if metadataCalls != 0 { + t.Errorf("metadata calls = %d, want 0 for autorename", metadataCalls) + } + if mode != files.WriteModeAdd { + t.Errorf("write mode = %q, want %q", mode, files.WriteModeAdd) + } + if !autorename { + t.Error("upload arg Autorename = false, want true") + } + if result.Status != putStatusAutorenamed { + t.Errorf("status = %q, want %q", result.Status, putStatusAutorenamed) + } +} + +func TestPutFileIfExistsAutorenameNoConflictReportsUploaded(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + mock := &mockFilesClient{ + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + return &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/new.txt"}}, nil + }, + } + stubFilesClient(t, mock) + + result, err := putFileWithResult(tmpFile, "/new.txt", putOptions{ + chunkSize: 1 << 24, + workers: 4, + ifExists: putIfExistsAutorename, + }) + if err != nil { + t.Fatalf("putFileWithResult error: %v", err) + } + if result.Status != putStatusUploaded { + t.Errorf("status = %q, want %q", result.Status, putStatusUploaded) + } +} + +func TestPutFileIfExistsAutorenameCaseOnlyPathReportsUploaded(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + mock := &mockFilesClient{ + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + return &files.FileMetadata{Metadata: files.Metadata{ + PathDisplay: "/Reports/New.txt", + PathLower: "/reports/new.txt", + }}, nil + }, + } + stubFilesClient(t, mock) + + result, err := putFileWithResult(tmpFile, "/reports/new.txt", putOptions{ + chunkSize: 1 << 24, + workers: 4, + ifExists: putIfExistsAutorename, + }) + if err != nil { + t.Fatalf("putFileWithResult error: %v", err) + } + if result.Status != putStatusUploaded { + t.Errorf("status = %q, want %q", result.Status, putStatusUploaded) + } +} + func TestPutFileIfExistsSkipTreatsUploadFileConflictAsSkip(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "test.txt") if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { diff --git a/cmd/relocation_if_exists.go b/cmd/relocation_if_exists.go index e0b58e99..07983cd6 100644 --- a/cmd/relocation_if_exists.go +++ b/cmd/relocation_if_exists.go @@ -8,8 +8,9 @@ import ( ) const ( - relocationIfExistsFail = "fail" - relocationIfExistsSkip = "skip" + relocationIfExistsFail = "fail" + relocationIfExistsSkip = "skip" + relocationIfExistsAutorename = "autorename" ) type relocationOptions struct { @@ -41,8 +42,10 @@ func normalizeRelocationIfExists(ifExists string) (string, error) { return relocationIfExistsFail, nil case relocationIfExistsSkip: return relocationIfExistsSkip, nil + case relocationIfExistsAutorename: + return relocationIfExistsAutorename, nil default: - return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use fail or skip)", flagValueErrorDetails("if-exists", ifExists), ifExists) + return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use fail, skip, or autorename)", flagValueErrorDetails("if-exists", ifExists), ifExists) } } diff --git a/cmd/relocation_output.go b/cmd/relocation_output.go index a09a9b7a..b97ddf93 100644 --- a/cmd/relocation_output.go +++ b/cmd/relocation_output.go @@ -3,9 +3,10 @@ package cmd import "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" const ( - relocationJSONStatusCopied = "copied" - relocationJSONStatusMoved = "moved" - relocationJSONStatusSkipped = "skipped" + relocationJSONStatusCopied = "copied" + relocationJSONStatusMoved = "moved" + relocationJSONStatusSkipped = "skipped" + relocationJSONStatusAutorenamed = "autorenamed" ) type relocationInput struct { @@ -45,3 +46,13 @@ func newRelocationResultFromMetadata(arg *files.RelocationArg, metadata files.Is func relocationOperationResult(status string, result relocationResult) jsonOperationResult { return newJSONOperationResult(status, result.Result.Type, result.Input, result.Result) } + +// relocationSuccessStatus returns the JSON status for a successful copy/move. +// When --if-exists=autorename caused the server to pick a different path than +// requested, it reports "autorenamed"; otherwise it keeps baseStatus. +func relocationSuccessStatus(baseStatus string, arg *files.RelocationArg, result relocationResult, opts relocationOptions) string { + if opts.ifExists == relocationIfExistsAutorename && !sameDropboxMetadataPath(result.Result.PathDisplay, result.Result.PathLower, arg.ToPath) { + return relocationJSONStatusAutorenamed + } + return baseStatus +} diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 5b94ab97..4be3334f 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "io" - "strings" "time" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" @@ -400,10 +399,6 @@ func nonEmptyString(value string) (string, bool) { return value, value != "" } -func sameDropboxPath(a string, b string) bool { - return strings.EqualFold(cleanDropboxPath(a), cleanDropboxPath(b)) -} - func shareLinkExpiresFlag(cmd *cobra.Command) (*time.Time, error) { value, err := cmd.Flags().GetString("expires") if err != nil { diff --git a/cmd/testdata/json_contract/error_outputs.json b/cmd/testdata/json_contract/error_outputs.json index a8c087bc..7a223243 100644 --- a/cmd/testdata/json_contract/error_outputs.json +++ b/cmd/testdata/json_contract/error_outputs.json @@ -68,7 +68,7 @@ "schema_version": "1", "command": "put", "error": { - "message": "invalid --if-exists \"replace\" (use overwrite, skip, or fail)", + "message": "invalid --if-exists \"replace\" (use overwrite, skip, autorename, or fail)", "code": "invalid_arguments", "details": { "flag": "if-exists", diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index e97108ea..cf672d84 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -416,6 +416,7 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ + "autorenamed", "copied", "skipped" ], @@ -526,6 +527,7 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ + "autorenamed", "moved", "skipped" ], @@ -543,6 +545,7 @@ "result_input": "put_result_input", "result": "metadata", "statuses": [ + "autorenamed", "created", "existing", "skipped", diff --git a/docs/commands/dbxcli_cp.md b/docs/commands/dbxcli_cp.md index 0b0e665e..e31f6c90 100644 --- a/docs/commands/dbxcli_cp.md +++ b/docs/commands/dbxcli_cp.md @@ -12,7 +12,7 @@ dbxcli cp [flags] [more sources] ``` -h, --help help for cp - --if-exists string What to do when the destination exists: fail or skip (default "fail") + --if-exists string What to do when the destination exists: fail, skip, or autorename (default "fail") ``` ### Options inherited from parent commands @@ -32,8 +32,8 @@ dbxcli cp [flags] [more sources] * Auth modes: `personal`, `team-access` * Dropbox scopes: `files.content.write`, `files.metadata.read` * Arguments: `source` (required, dropbox_path, variadic), `target` (required, dropbox_path) -* Flag metadata: `--if-exists` (values: `fail`, `skip`), `--output` (values: `json`, `text`) -* Result statuses: `copied`, `skipped` +* Flag metadata: `--if-exists` (values: `autorename`, `fail`, `skip`), `--output` (values: `json`, `text`) +* Result statuses: `autorenamed`, `copied`, `skipped` * Result kinds: `deleted`, `file`, `folder` * JSON contract: `docs/json-schema/v1/commands.json#/commands/cp` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_cp` diff --git a/docs/commands/dbxcli_mv.md b/docs/commands/dbxcli_mv.md index e0f30ae2..4afdb1cf 100644 --- a/docs/commands/dbxcli_mv.md +++ b/docs/commands/dbxcli_mv.md @@ -12,7 +12,7 @@ dbxcli mv [flags] [more sources] ``` -h, --help help for mv - --if-exists string What to do when the destination exists: fail or skip (default "fail") + --if-exists string What to do when the destination exists: fail, skip, or autorename (default "fail") ``` ### Options inherited from parent commands @@ -32,8 +32,8 @@ dbxcli mv [flags] [more sources] * Auth modes: `personal`, `team-access` * Dropbox scopes: `files.content.write`, `files.metadata.read` * Arguments: `source` (required, dropbox_path, variadic), `target` (required, dropbox_path) -* Flag metadata: `--if-exists` (values: `fail`, `skip`), `--output` (values: `json`, `text`) -* Result statuses: `moved`, `skipped` +* Flag metadata: `--if-exists` (values: `autorename`, `fail`, `skip`), `--output` (values: `json`, `text`) +* Result statuses: `autorenamed`, `moved`, `skipped` * Result kinds: `deleted`, `file`, `folder` * JSON contract: `docs/json-schema/v1/commands.json#/commands/mv` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_mv` diff --git a/docs/commands/dbxcli_put.md b/docs/commands/dbxcli_put.md index 63b55408..69233d4e 100644 --- a/docs/commands/dbxcli_put.md +++ b/docs/commands/dbxcli_put.md @@ -37,7 +37,7 @@ dbxcli put [flags] [] -c, --chunksize int Chunk size in bytes for chunked large-file uploads; must be a multiple of 4MiB and no more than 128MiB (default 16777216) -d, --debug Print debug timing -h, --help help for put - --if-exists string What to do when the destination file exists: overwrite, skip, or fail (default "overwrite") + --if-exists string What to do when the destination file exists: overwrite, skip, autorename, or fail (default "overwrite") -r, --recursive Recursively upload directories -w, --workers int Number of concurrent upload workers for chunked large-file uploads (default 4) ``` @@ -59,9 +59,9 @@ dbxcli put [flags] [] * Auth modes: `personal`, `team-access` * Dropbox scopes: `files.content.write`, `files.metadata.read` * Arguments: `source` (required, local_path, `-` stream operand), `target` (optional, dropbox_path) -* Flag metadata: `--if-exists` (values: `fail`, `overwrite`, `skip`), `--output` (values: `json`, `text`) +* Flag metadata: `--if-exists` (values: `autorename`, `fail`, `overwrite`, `skip`), `--output` (values: `json`, `text`) * Stdin/stdout behavior: Use `-` as the local source to upload from stdin; stdin is spooled to a temporary file before upload. -* Result statuses: `created`, `existing`, `skipped`, `uploaded` +* Result statuses: `autorenamed`, `created`, `existing`, `skipped`, `uploaded` * Result kinds: `file`, `folder` * Warning codes: `skipped_symlink` * JSON contract: `docs/json-schema/v1/commands.json#/commands/put` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index e97108ea..cf672d84 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -416,6 +416,7 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ + "autorenamed", "copied", "skipped" ], @@ -526,6 +527,7 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ + "autorenamed", "moved", "skipped" ], @@ -543,6 +545,7 @@ "result_input": "put_result_input", "result": "metadata", "statuses": [ + "autorenamed", "created", "existing", "skipped", diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 79b7ce20..d5230d6d 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1814,6 +1814,7 @@ "properties": { "if_exists": { "enum": [ + "autorename", "fail", "overwrite", "skip" @@ -1960,6 +1961,7 @@ }, "status": { "enum": [ + "autorenamed", "copied", "skipped" ] @@ -2166,6 +2168,7 @@ }, "status": { "enum": [ + "autorenamed", "moved", "skipped" ] @@ -2196,6 +2199,7 @@ }, "status": { "enum": [ + "autorenamed", "created", "existing", "skipped", diff --git a/internal/jsonschema/definition_schemas.go b/internal/jsonschema/definition_schemas.go index fadbabf6..46284cba 100644 --- a/internal/jsonschema/definition_schemas.go +++ b/internal/jsonschema/definition_schemas.go @@ -209,7 +209,7 @@ var commandDefinitionSchemas = map[string]definitionSchemaConfig{ "put_input": { Required: []string{"if_exists", "recursive", "source", "stdin", "target"}, Properties: map[string]any{ - "if_exists": stringEnum("fail", "overwrite", "skip"), + "if_exists": stringEnum("fail", "overwrite", "skip", "autorename"), }, }, "put_result_input": {