diff --git a/backend/drive/drive.go b/backend/drive/drive.go index d651e567e..357506dd2 100755 --- a/backend/drive/drive.go +++ b/backend/drive/drive.go @@ -37,6 +37,7 @@ import ( "github.com/rclone/rclone/fs/fserrors" "github.com/rclone/rclone/fs/fshttp" "github.com/rclone/rclone/fs/hash" + "github.com/rclone/rclone/fs/operations" "github.com/rclone/rclone/fs/walk" "github.com/rclone/rclone/lib/dircache" "github.com/rclone/rclone/lib/encoder" @@ -69,7 +70,7 @@ const ( // 1<<18 is the minimum size supported by the Google uploader, and there is no maximum. minChunkSize = 256 * fs.KibiByte defaultChunkSize = 8 * fs.MebiByte - partialFields = "id,name,size,md5Checksum,trashed,modifiedTime,createdTime,mimeType,parents,webViewLink,shortcutDetails" + partialFields = "id,name,size,md5Checksum,trashed,explicitlyTrashed,modifiedTime,createdTime,mimeType,parents,webViewLink,shortcutDetails" listRGrouping = 50 // number of IDs to search at once when using ListR listRInputBuffer = 1000 // size of input buffer when using ListR ) @@ -2869,6 +2870,75 @@ func (f *Fs) listTeamDrives(ctx context.Context) (drives []*drive.TeamDrive, err return drives, nil } +type unTrashResult struct { + Untrashed int + Errors int +} + +func (r unTrashResult) Error() string { + return fmt.Sprintf("%d errors while untrashing - see log", r.Errors) +} + +// Restore the trashed files from dir, directoryID recursing if needed +func (f *Fs) unTrash(ctx context.Context, dir string, directoryID string, recurse bool) (r unTrashResult, err error) { + directoryID = actualID(directoryID) + fs.Debugf(dir, "finding trash to restore in directory %q", directoryID) + _, err = f.list(ctx, []string{directoryID}, "", false, false, true, func(item *drive.File) bool { + remote := path.Join(dir, item.Name) + if item.ExplicitlyTrashed { + fs.Infof(remote, "restoring %q", item.Id) + if operations.SkipDestructive(ctx, remote, "restore") { + return false + } + update := drive.File{ + ForceSendFields: []string{"Trashed"}, // necessary to set false value + Trashed: false, + } + err := f.pacer.Call(func() (bool, error) { + _, err := f.svc.Files.Update(item.Id, &update). + SupportsAllDrives(true). + Fields("trashed"). + Do() + return f.shouldRetry(err) + }) + if err != nil { + err = errors.Wrap(err, "failed to restore") + r.Errors++ + fs.Errorf(remote, "%v", err) + } else { + r.Untrashed++ + } + } + if recurse && item.MimeType == "application/vnd.google-apps.folder" { + if !isShortcutID(item.Id) { + rNew, _ := f.unTrash(ctx, remote, item.Id, recurse) + r.Untrashed += rNew.Untrashed + r.Errors += rNew.Errors + } + } + return false + }) + if err != nil { + err = errors.Wrap(err, "failed to list directory") + r.Errors++ + fs.Errorf(dir, "%v", err) + } + if r.Errors != 0 { + return r, r + } + return r, nil +} + +// Untrash dir +func (f *Fs) unTrashDir(ctx context.Context, dir string, recurse bool) (r unTrashResult, err error) { + directoryID, err := f.dirCache.FindDir(ctx, dir, false) + if err != nil { + r.Errors++ + return r, err + } + return f.unTrash(ctx, dir, directoryID, true) +} + var commandHelp = []fs.CommandHelp{{ Name: "get", Short: "Get command for fetching the drive config parameters", @@ -2946,6 +3016,29 @@ This will return a JSON list of objects like this ] `, +}, { + Name: "untrash", + Short: "Untrash files and directories", + Long: `This command untrashes all the files and directories in the directory +passed in recursively. + +Usage: + +This takes an optional directory to trash which make this easier to +use via the API. + + rclone backend untrash drive:directory + rclone backend -i untrash drive:directory subdir + +Use the -i flag to see what would be restored before restoring it. + +Result: + + { + "Untrashed": 17, + "Errors": 0 + } +`, }} // Command the backend to run a named command @@ -3011,6 +3104,12 @@ func (f *Fs) Command(ctx context.Context, name string, arg []string, opt map[str return f.makeShortcut(ctx, arg[0], dstFs, arg[1]) case "drives": return f.listTeamDrives(ctx) + case "untrash": + dir := "" + if len(arg) > 0 { + dir = arg[0] + } + return f.unTrashDir(ctx, dir, true) default: return nil, fs.ErrorCommandNotFound } diff --git a/backend/drive/drive_internal_test.go b/backend/drive/drive_internal_test.go index 9e55b9f31..334191bae 100644 --- a/backend/drive/drive_internal_test.go +++ b/backend/drive/drive_internal_test.go @@ -10,13 +10,16 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/pkg/errors" _ "github.com/rclone/rclone/backend/local" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/hash" "github.com/rclone/rclone/fs/operations" + "github.com/rclone/rclone/fstest" "github.com/rclone/rclone/fstest/fstests" + "github.com/rclone/rclone/lib/random" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/api/drive/v3" @@ -361,6 +364,50 @@ func (f *Fs) InternalTestShortcuts(t *testing.T) { }) } +// TestIntegration/FsMkdir/FsPutFiles/Internal/UnTrash +func (f *Fs) InternalTestUnTrash(t *testing.T) { + ctx := context.Background() + + // Make some objects, one in a subdir + contents := random.String(100) + file1 := fstest.NewItem("trashDir/toBeTrashed", contents, time.Now()) + _, obj1 := fstests.PutTestContents(ctx, t, f, &file1, contents, false) + file2 := fstest.NewItem("trashDir/subdir/toBeTrashed", contents, time.Now()) + _, _ = fstests.PutTestContents(ctx, t, f, &file2, contents, false) + + // Check objects + checkObjects := func() { + fstest.CheckListingWithRoot(t, f, "trashDir", []fstest.Item{ + file1, + file2, + }, []string{ + "trashDir/subdir", + }, f.Precision()) + } + checkObjects() + + // Make sure we are using the trash + require.Equal(t, true, f.opt.UseTrash) + + // Remove the object and the dir + require.NoError(t, obj1.Remove(ctx)) + require.NoError(t, f.Purge(ctx, "trashDir/subdir")) + + // Check objects gone + fstest.CheckListingWithRoot(t, f, "trashDir", []fstest.Item{}, []string{}, f.Precision()) + + // Restore the object and directory + r, err := f.unTrashDir(ctx, "trashDir", true) + require.NoError(t, err) + assert.Equal(t, unTrashResult{Errors: 0, Untrashed: 2}, r) + + // Check objects restored + checkObjects() + + // Remove the test dir + require.NoError(t, f.Purge(ctx, "trashDir")) +} + func (f *Fs) InternalTest(t *testing.T) { // These tests all depend on each other so run them as nested tests t.Run("DocumentImport", func(t *testing.T) { @@ -376,6 +423,7 @@ func (f *Fs) InternalTest(t *testing.T) { }) }) t.Run("Shortcuts", f.InternalTestShortcuts) + t.Run("UnTrash", f.InternalTestUnTrash) } var _ fstests.InternalTester = (*Fs)(nil)