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
20 changes: 15 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <folder-id> --name "Q3 Report.pdf"
gwcli drive upload *.csv --folder <folder-id> --convert # CSV->Sheet, MD->Doc, ...
gwcli drive upload notes.txt --folder <folder-id> --as sheet # force converted-to type
gwcli drive upload ./package-dir --folder <folder-id> # recurses, mirrors structure
gwcli drive upload ./01-catalog.csv --folder <folder-id> --upsert # idempotent rerun
gwcli drive update <file-id|url> ./report.pdf --name "Q3 Report (final).pdf"
Expand Down Expand Up @@ -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
`'<id>' in parents` clause); `drive search <term>` 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
Expand Down Expand Up @@ -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 <folder>` recurses the folder tree
(`exportDriveFolder`), exporting every file into a local directory mirroring
Expand Down
11 changes: 10 additions & 1 deletion claude-skill-gwcli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <folder-id> --name "Q3 Report.pdf"
gwcli drive upload *.csv --folder <folder-id> --convert # CSV->Sheet, MD->Doc...
gwcli drive upload notes.txt --folder <folder-id> --as sheet # force converted-to type
gwcli drive upload ./package-dir --folder <folder-id> # recurses with mkdir
gwcli drive upload ./01-catalog.csv --folder <folder-id> --upsert # safe rerun
gwcli drive update <file-id|url> ./report.pdf --name "Q3 Report (final).pdf"
Expand Down Expand Up @@ -456,7 +457,15 @@ gwcli drive permissions <file-id|url> # 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 <id> --force`; combine with the
`id` from the upload's JSON for a clean rollback.

Expand Down
16 changes: 14 additions & 2 deletions drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down
111 changes: 108 additions & 3 deletions drive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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)
}
}
Loading
Loading