From e954742d167f1345b1c81065a4fb5c742466361f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 13:33:08 +0000 Subject: [PATCH 1/2] Fix Drive upload conversion mappings and trashed-item leak - Declare an explicit source content type by extension on upload media (driveSourceMimeByExt/driveMediaOption). Drive's import converter keys off the uploaded media's MIME, not the target metadata, so a .csv was content-sniffed as text/plain and imported as a Doc despite the google-apps.spreadsheet target. This is the root cause of --convert producing Docs for CSV/Sheets/Slides. - Add `drive upload --as doc|sheet|slides|drawing|form` (or a raw application/vnd.google-apps.* type) to force the converted-to type, overriding extension inference and implying conversion. - Default `drive list`/`drive search` to `trashed = false` so trashed children no longer leak into folder/search listings; an explicit `trashed` clause in --query still takes precedence. https://claude.ai/code/session_01K3CYduZ3LYpiYPSuButL56 --- CLAUDE.md | 20 ++++++--- drive.go | 16 ++++++- drive_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++++++-- drive_write.go | 96 ++++++++++++++++++++++++++++++++++++++---- main.go | 4 +- 5 files changed, 227 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d7c566b..f590876 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -383,6 +383,7 @@ gwcli drive search "quarterly plan" # Write ops (full drive scope, unblocked since #40 took the full scope) gwcli drive upload ./report.pdf --folder --name "Q3 Report.pdf" gwcli drive upload *.csv --folder --convert # CSV->Sheet, MD->Doc, ... +gwcli drive upload notes.txt --folder --as sheet # force converted-to type gwcli drive upload ./package-dir --folder # recurses, mirrors structure gwcli drive upload ./01-catalog.csv --folder --upsert # idempotent rerun gwcli drive update ./report.pdf --name "Q3 Report (final).pdf" @@ -420,7 +421,9 @@ download content but still needs the Drive scope (`Files.Get` metadata). (`pdf`/`md`/`docx`/`xlsx`/`csv`/`png`/...) or a raw MIME type. `drive list` takes a raw Drive `q` expression plus an optional `--folder` (adds a `'' in parents` clause); `drive search ` wraps a term into -`name contains / fullText contains`. Both paginate via `Files.List` with +`name contains / fullText contains`. Both default to `trashed = false` +(trashed items otherwise leak into folder/search listings); a `--query` +that explicitly mentions `trashed` keeps full control. Both paginate via `Files.List` with `SupportsAllDrives`/`IncludeItemsFromAllDrives`/`Corpora("allDrives")` and a `--limit` cap (0 = no cap). `drive upload`/`drive update` are `Files.Create`/ `Files.Update` media uploads (write ops are intentionally unblocked because @@ -464,10 +467,17 @@ globs like `*.csv` expand to multiple args) and directories. A directory is walked recursively and mirrored into Drive, creating folders via the same idempotent `ensureDriveFolder`. `--convert` converts by local extension to the native Google type (`driveConvertByExt`: csv/tsv/xls*→Sheet, -txt/md/doc*/html→Doc, ppt*→Slides). `--upsert` replaces an existing -same-name file in the destination (via `findDriveChildByName` → -`Files.Update` media) instead of creating `name (1)`, `name (2)` on every -rerun. `--name` is single-file only. +txt/md/doc*/html→Doc, ppt*→Slides). `--as doc|sheet|slides|drawing|form` +(or a raw `application/vnd.google-apps.*` type, `resolveDriveTargetMime`) +forces the converted-to type, overriding extension inference, and implies +conversion without `--convert`. Crucially, the upload always declares an +explicit *source* content type by extension (`driveSourceMimeByExt` / +`driveMediaOption`): Drive's import converter keys off the uploaded media's +MIME, not the target metadata, so without this a `.csv` is content-sniffed +as `text/plain` and lands as a Doc even with the spreadsheet target. +`--upsert` replaces an existing same-name file in the destination (via +`findDriveChildByName` → `Files.Update` media) instead of creating +`name (1)`, `name (2)` on every rerun. `--name` is single-file only. **Folder export.** `drive export ` recurses the folder tree (`exportDriveFolder`), exporting every file into a local directory mirroring diff --git a/drive.go b/drive.go index 58af5ef..8ed6990 100644 --- a/drive.go +++ b/drive.go @@ -9,6 +9,7 @@ import ( "github.com/wesnick/gwcli/pkg/gwcli" drive "google.golang.org/api/drive/v3" + "google.golang.org/api/googleapi" ) // resolveDriveRef accepts either a raw Drive file ID or a Drive/Docs URL and @@ -297,6 +298,12 @@ func runDriveList(ctx context.Context, conn *gwcli.CmdG, query, folder string, l if folder != "" { clauses = append(clauses, fmt.Sprintf("%q in parents", folder)) } + // Hide trashed items by default (they otherwise leak into folder/listing + // results). A caller who explicitly filters on `trashed` in --query keeps + // full control. + if !strings.Contains(query, "trashed") { + clauses = append(clauses, "trashed = false") + } files, err := driveList(ctx, conn, strings.Join(clauses, " and "), limit) if err != nil { return err @@ -311,7 +318,7 @@ func runDriveSearch(ctx context.Context, conn *gwcli.CmdG, term string, limit in return fmt.Errorf("a search term is required") } esc := strings.ReplaceAll(term, "'", `\'`) - q := fmt.Sprintf("name contains '%s' or fullText contains '%s'", esc, esc) + q := fmt.Sprintf("(name contains '%s' or fullText contains '%s') and trashed = false", esc, esc) files, err := driveList(ctx, conn, q, limit) if err != nil { return err @@ -343,8 +350,13 @@ func runDriveUpdate(ctx context.Context, conn *gwcli.CmdG, ref, path, name strin meta.Name = name } + var mediaOpts []googleapi.MediaOption + if opt := driveMediaOption(path); opt != nil { + mediaOpts = append(mediaOpts, opt) + } + updated, err := svc.Files.Update(art.ID, meta). - Media(f). + Media(f, mediaOpts...). SupportsAllDrives(true). Fields(driveWriteFields). Context(ctx). diff --git a/drive_test.go b/drive_test.go index 7aec31a..1a7f368 100644 --- a/drive_test.go +++ b/drive_test.go @@ -140,9 +140,11 @@ func TestRunDriveExport_FormatOverride(t *testing.T) { } func TestRunDriveList(t *testing.T) { + var gotQuery string client := &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if strings.HasSuffix(req.URL.Path, "/drive/v3/files") { + gotQuery = req.URL.Query().Get("q") body := `{"files":[{"id":"F1","name":"a.txt","mimeType":"text/plain","size":"12"},` + `{"id":"F2","name":"b.pdf","mimeType":"application/pdf","size":"34"}]}` return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil @@ -157,9 +159,12 @@ func TestRunDriveList(t *testing.T) { var buf bytes.Buffer out := &outputWriter{json: true, writer: &buf} - if err := runDriveList(context.Background(), conn, "", "", 100, out); err != nil { + if err := runDriveList(context.Background(), conn, "", "FOLDER1", 100, out); err != nil { t.Fatalf("runDriveList() error = %v", err) } + if !strings.Contains(gotQuery, "trashed = false") { + t.Fatalf("list query %q missing trashed = false filter", gotQuery) + } var got []driveListFile if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v (raw %s)", err, buf.String()) @@ -193,7 +198,7 @@ func TestRunDriveUpload(t *testing.T) { var buf bytes.Buffer out := &outputWriter{json: true, writer: &buf} - if err := runDriveUpload(context.Background(), conn, []string{src}, "FOLDER1", "", false, false, out); err != nil { + if err := runDriveUpload(context.Background(), conn, []string{src}, "FOLDER1", "", false, false, "", out); err != nil { t.Fatalf("runDriveUpload() error = %v", err) } var got map[string]interface{} @@ -348,7 +353,7 @@ func TestRunDriveUpload_UpsertReplacesExisting(t *testing.T) { } var buf bytes.Buffer out := &outputWriter{json: true, writer: &buf} - if err := runDriveUpload(context.Background(), conn, []string{src}, "FOLDER", "", false, true, out); err != nil { + if err := runDriveUpload(context.Background(), conn, []string{src}, "FOLDER", "", false, true, "", out); err != nil { t.Fatalf("runDriveUpload(upsert) error = %v", err) } if updatedID != "EXIST" { @@ -376,3 +381,103 @@ func TestResolveExportFormat(t *testing.T) { t.Errorf("resolveExportFormat(\"\") ok = true, want false") } } + +func TestResolveDriveTargetMime(t *testing.T) { + cases := map[string]string{ + "": "", + "doc": "application/vnd.google-apps.document", + "Document": "application/vnd.google-apps.document", + "sheet": "application/vnd.google-apps.spreadsheet", + "spreadsheet": "application/vnd.google-apps.spreadsheet", + "slides": "application/vnd.google-apps.presentation", + "presentation": "application/vnd.google-apps.presentation", + "drawing": "application/vnd.google-apps.drawing", + "form": "application/vnd.google-apps.form", + "application/vnd.google-apps.script": "application/vnd.google-apps.script", + } + for in, want := range cases { + got, err := resolveDriveTargetMime(in) + if err != nil { + t.Errorf("resolveDriveTargetMime(%q) unexpected error %v", in, err) + } + if got != want { + t.Errorf("resolveDriveTargetMime(%q) = %q, want %q", in, got, want) + } + } + if _, err := resolveDriveTargetMime("xlsx"); err == nil { + t.Errorf("resolveDriveTargetMime(xlsx) err = nil, want error") + } + if _, err := resolveDriveTargetMime("application/pdf"); err == nil { + t.Errorf("resolveDriveTargetMime(application/pdf) err = nil, want error") + } +} + +// TestRunDriveUpload_ConvertCSVToSheet verifies a .csv upload with --convert +// declares the spreadsheet target *and* a text/csv source content type (so +// Drive does not content-sniff it as text/plain and import it as a Doc). +func TestRunDriveUpload_ConvertCSVToSheet(t *testing.T) { + var body string + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/upload/drive/v3/files") { + b, _ := io.ReadAll(req.Body) + body = string(b) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"S1","name":"data.csv","mimeType":"application/vnd.google-apps.spreadsheet"}`))}, nil + } + return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + }), + } + conn, err := gwcli.NewFake(client) + if err != nil { + t.Fatalf("NewFake() error = %v", err) + } + dir := t.TempDir() + src := filepath.Join(dir, "data.csv") + if err := os.WriteFile(src, []byte("a,b\n1,2\n"), 0644); err != nil { + t.Fatalf("write src: %v", err) + } + var buf bytes.Buffer + out := &outputWriter{json: true, writer: &buf} + if err := runDriveUpload(context.Background(), conn, []string{src}, "FOLDER", "", true, false, "", out); err != nil { + t.Fatalf("runDriveUpload(convert) error = %v", err) + } + if !strings.Contains(body, "application/vnd.google-apps.spreadsheet") { + t.Fatalf("upload body missing spreadsheet target mimeType: %s", body) + } + if !strings.Contains(body, "text/csv") { + t.Fatalf("upload body missing text/csv source content type: %s", body) + } +} + +// TestRunDriveUpload_AsOverride verifies --as forces the target type even +// without --convert and overrides extension inference. +func TestRunDriveUpload_AsOverride(t *testing.T) { + var body string + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if strings.Contains(req.URL.Path, "/upload/drive/v3/files") { + b, _ := io.ReadAll(req.Body) + body = string(b) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":"D1","name":"notes.csv","mimeType":"application/vnd.google-apps.document"}`))}, nil + } + return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + }), + } + conn, err := gwcli.NewFake(client) + if err != nil { + t.Fatalf("NewFake() error = %v", err) + } + dir := t.TempDir() + src := filepath.Join(dir, "notes.csv") + if err := os.WriteFile(src, []byte("a,b\n1,2\n"), 0644); err != nil { + t.Fatalf("write src: %v", err) + } + var buf bytes.Buffer + out := &outputWriter{json: true, writer: &buf} + if err := runDriveUpload(context.Background(), conn, []string{src}, "FOLDER", "", false, false, "doc", out); err != nil { + t.Fatalf("runDriveUpload(as=doc) error = %v", err) + } + if !strings.Contains(body, "application/vnd.google-apps.document") { + t.Fatalf("upload body missing document target mimeType from --as: %s", body) + } +} diff --git a/drive_write.go b/drive_write.go index 4429d53..e5dfda5 100644 --- a/drive_write.go +++ b/drive_write.go @@ -9,6 +9,7 @@ import ( "github.com/wesnick/gwcli/pkg/gwcli" drive "google.golang.org/api/drive/v3" + "google.golang.org/api/googleapi" ) // driveFolderMime is the well-known mimeType Google Drive uses for folders. @@ -41,6 +42,71 @@ var driveConvertByExt = map[string]string{ ".odp": "application/vnd.google-apps.presentation", } +// driveSourceMimeByExt maps a local file extension to the *source* content +// type to declare on the media upload. Drive's import converter keys the +// conversion off the uploaded media's MIME type, not the target metadata +// mimeType — so without this a .csv is content-sniffed as text/plain and +// imported as a Doc even when the target is google-apps.spreadsheet. +var driveSourceMimeByExt = map[string]string{ + ".csv": "text/csv", + ".tsv": "text/tab-separated-values", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".txt": "text/plain", + ".md": "text/markdown", + ".rtf": "application/rtf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".odt": "application/vnd.oasis.opendocument.text", + ".html": "text/html", + ".htm": "text/html", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".json": "application/json", +} + +// driveAsAliases maps the friendly --as values to native Google-apps +// mimeTypes. A raw "application/vnd.google-apps.*" value is also accepted +// verbatim by resolveDriveTargetMime. +var driveAsAliases = map[string]string{ + "doc": "application/vnd.google-apps.document", + "document": "application/vnd.google-apps.document", + "sheet": "application/vnd.google-apps.spreadsheet", + "spreadsheet": "application/vnd.google-apps.spreadsheet", + "slides": "application/vnd.google-apps.presentation", + "presentation": "application/vnd.google-apps.presentation", + "drawing": "application/vnd.google-apps.drawing", + "form": "application/vnd.google-apps.form", +} + +// resolveDriveTargetMime turns an --as value into a native Google-apps +// mimeType. It accepts a friendly alias (doc/sheet/slides/...) or a raw +// "application/vnd.google-apps.*" type. Empty input yields "" (no override). +func resolveDriveTargetMime(as string) (string, error) { + as = strings.TrimSpace(as) + if as == "" { + return "", nil + } + if m, ok := driveAsAliases[strings.ToLower(as)]; ok { + return m, nil + } + if strings.HasPrefix(as, "application/vnd.google-apps.") { + return as, nil + } + return "", fmt.Errorf("unknown --as value %q (use doc, sheet, slides, drawing, form, or a raw application/vnd.google-apps.* type)", as) +} + +// driveMediaOption returns the explicit source-content-type media option for +// localPath, or nil when the extension is unknown (let the library sniff). +func driveMediaOption(localPath string) googleapi.MediaOption { + if m, ok := driveSourceMimeByExt[strings.ToLower(filepath.Ext(localPath))]; ok { + return googleapi.ContentType(m) + } + return nil +} + // driveQuoteValue escapes a value for embedding inside a single-quoted Drive // query string literal. func driveQuoteValue(v string) string { @@ -473,7 +539,7 @@ func runDrivePermissions(ctx context.Context, conn *gwcli.CmdG, ref string, out // uploadOneFile uploads a single local file into parent. With convert it is // converted to the native Google-apps type for its extension; with upsert an // existing same-name file in the parent is replaced instead of duplicated. -func uploadOneFile(ctx context.Context, svc *drive.Service, localPath, parent, name string, convert, upsert bool) (*drive.File, error) { +func uploadOneFile(ctx context.Context, svc *drive.Service, localPath, parent, name string, convert, upsert bool, targetMimeOverride string) (*drive.File, error) { fh, err := os.Open(localPath) if err != nil { return nil, fmt.Errorf("failed to open %s: %w", localPath, err) @@ -484,12 +550,20 @@ func uploadOneFile(ctx context.Context, svc *drive.Service, localPath, parent, n name = filepath.Base(localPath) } targetMime := "" - if convert { + switch { + case targetMimeOverride != "": + targetMime = targetMimeOverride + case convert: if m, ok := driveConvertByExt[strings.ToLower(filepath.Ext(localPath))]; ok { targetMime = m } } + var mediaOpts []googleapi.MediaOption + if opt := driveMediaOption(localPath); opt != nil { + mediaOpts = append(mediaOpts, opt) + } + if upsert { existing, err := findDriveChildByName(ctx, svc, name, parent, "") if err != nil { @@ -501,7 +575,7 @@ func uploadOneFile(ctx context.Context, svc *drive.Service, localPath, parent, n meta.MimeType = targetMime } updated, err := svc.Files.Update(existing.Id, meta). - Media(fh). + Media(fh, mediaOpts...). SupportsAllDrives(true). Fields(driveWriteFields). Context(ctx). @@ -521,7 +595,7 @@ func uploadOneFile(ctx context.Context, svc *drive.Service, localPath, parent, n meta.MimeType = targetMime } created, err := svc.Files.Create(meta). - Media(fh). + Media(fh, mediaOpts...). SupportsAllDrives(true). Fields(driveWriteFields). Context(ctx). @@ -535,7 +609,7 @@ func uploadOneFile(ctx context.Context, svc *drive.Service, localPath, parent, n // uploadTree walks a local directory, mirroring its structure into Drive // (folders created idempotently) and uploading every file. Returns the // uploaded/updated files. -func uploadTree(ctx context.Context, svc *drive.Service, root, parent string, convert, upsert bool) ([]*drive.File, error) { +func uploadTree(ctx context.Context, svc *drive.Service, root, parent string, convert, upsert bool, targetMimeOverride string) ([]*drive.File, error) { // dirFolderID caches the Drive folder ID for each local directory so a // folder is only created/looked-up once. dirFolderID := map[string]string{} @@ -565,7 +639,7 @@ func uploadTree(ctx context.Context, svc *drive.Service, root, parent string, co return nil } parentID := dirFolderID[filepath.Clean(filepath.Dir(path))] - f, err := uploadOneFile(ctx, svc, path, parentID, "", convert, upsert) + f, err := uploadOneFile(ctx, svc, path, parentID, "", convert, upsert, targetMimeOverride) if err != nil { return err } @@ -582,11 +656,15 @@ func uploadTree(ctx context.Context, svc *drive.Service, root, parent string, co // Directories are mirrored recursively; --convert converts by extension; // --upsert replaces an existing same-name file in the destination instead of // creating a duplicate (idempotent reruns). -func runDriveUpload(ctx context.Context, conn *gwcli.CmdG, paths []string, folderRef, name string, convert, upsert bool, out *outputWriter) error { +func runDriveUpload(ctx context.Context, conn *gwcli.CmdG, paths []string, folderRef, name string, convert, upsert bool, as string, out *outputWriter) error { svc, err := driveSvc(conn) if err != nil { return err } + targetMimeOverride, err := resolveDriveTargetMime(as) + if err != nil { + return err + } parent := "" if folderRef != "" { dest, err := resolveDriveRef(folderRef) @@ -610,14 +688,14 @@ func runDriveUpload(ctx context.Context, conn *gwcli.CmdG, paths []string, folde if name != "" { return fmt.Errorf("--name cannot be used when uploading a directory") } - tree, err := uploadTree(ctx, svc, ep, parent, convert, upsert) + tree, err := uploadTree(ctx, svc, ep, parent, convert, upsert, targetMimeOverride) if err != nil { return err } results = append(results, tree...) continue } - f, err := uploadOneFile(ctx, svc, ep, parent, name, convert, upsert) + f, err := uploadOneFile(ctx, svc, ep, parent, name, convert, upsert, targetMimeOverride) if err != nil { return err } diff --git a/main.go b/main.go index 413c4f7..59b0882 100644 --- a/main.go +++ b/main.go @@ -200,6 +200,7 @@ type CLI struct { Folder string `name:"folder" help:"Destination folder ID or URL"` Name string `name:"name" help:"Override the uploaded file name (single file only)"` Convert bool `name:"convert" help:"Convert to the native Google-apps type by extension (csv->Sheet, md->Doc, ...)"` + As string `name:"as" help:"Force the converted-to Google type, overriding extension inference: doc|sheet|slides|drawing|form or a raw application/vnd.google-apps.* type"` Upsert bool `name:"upsert" help:"Replace an existing same-name file in the destination instead of creating a duplicate"` } `cmd:"" help:"Upload local file(s)/directory to Drive"` @@ -701,7 +702,8 @@ func main() { } if err := runDriveUpload(cmdCtx, conn, cli.Drive.Upload.Paths, cli.Drive.Upload.Folder, cli.Drive.Upload.Name, - cli.Drive.Upload.Convert, cli.Drive.Upload.Upsert, out); err != nil { + cli.Drive.Upload.Convert, cli.Drive.Upload.Upsert, + cli.Drive.Upload.As, out); err != nil { out.writeError(err) os.Exit(2) } From ea09f70a203b37a9a076d6b82f41e95fd8ea8441 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 13:51:33 +0000 Subject: [PATCH 2/2] Document --as override and trashed-default in SKILL.md Mirror the drive upload conversion fix and the trashed=false default for drive list/search into the agent-facing skill doc. https://claude.ai/code/session_01K3CYduZ3LYpiYPSuButL56 --- claude-skill-gwcli/SKILL.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/claude-skill-gwcli/SKILL.md b/claude-skill-gwcli/SKILL.md index df59007..cdc8fca 100644 --- a/claude-skill-gwcli/SKILL.md +++ b/claude-skill-gwcli/SKILL.md @@ -413,6 +413,7 @@ so the next step can chain without a lookup): # Upload: one or many paths, a glob, or a directory (recursed + mirrored) gwcli drive upload ./report.pdf --folder --name "Q3 Report.pdf" gwcli drive upload *.csv --folder --convert # CSV->Sheet, MD->Doc... +gwcli drive upload notes.txt --folder --as sheet # force converted-to type gwcli drive upload ./package-dir --folder # recurses with mkdir gwcli drive upload ./01-catalog.csv --folder --upsert # safe rerun gwcli drive update ./report.pdf --name "Q3 Report (final).pdf" @@ -456,7 +457,15 @@ gwcli drive permissions # who can access (a - `drive rm` requires `--force` (non-interactive). Default is trash (recoverable); `--permanent` is irreversible. - `--convert` maps by local extension (csv/tsv/xls*→Sheet, - txt/md/doc*/html→Doc, ppt*→Slides); other extensions upload as-is. + txt/md/doc*/html→Doc, ppt*→Slides); other extensions upload as-is. The + source content type is declared by extension so Drive converts to the + right native type instead of sniffing a `.csv` as text and making a Doc. +- `--as doc|sheet|slides|drawing|form` (or a raw + `application/vnd.google-apps.*` type) forces the converted-to type, + overriding extension inference and implying conversion without + `--convert`. +- `drive list`/`drive search` exclude trashed items by default; put an + explicit `trashed` clause in `--query` to override. - A bad upload is undone with `drive rm --force`; combine with the `id` from the upload's JSON for a clean rollback.