Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
ba9146512b fix: land sheets create parent move semantics (#424) (thanks @ManManavadaria)
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / windows (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
ci / windows-build (push) Has been cancelled
2026-03-07 15:31:27 +00:00
ManManavadaria
eef50acc60 sheets create: add --parent flag and move spreadsheet to parent folder using Drive service 2026-03-07 15:28:10 +00:00
3 changed files with 269 additions and 2 deletions

View File

@ -5,6 +5,7 @@
### Added
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.
- Sheets: add `sheets links` (alias `hyperlinks`) to list cell links from ranges, including rich-text links. (#374) — thanks @omothm.
- Sheets: add `sheets create --parent` to place new spreadsheets in a Drive folder. (#424) — thanks @ManManavadaria.
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
- Drive: add `drive ls --all` (alias `--global`) to list across all accessible files; make `--all` and `--parent` mutually exclusive. (#107) — thanks @struong.

View File

@ -8,6 +8,7 @@ import (
"strings"
"text/tabwriter"
"google.golang.org/api/drive/v3"
"google.golang.org/api/sheets/v4"
"github.com/steipete/gogcli/internal/googleapi"
@ -464,6 +465,7 @@ func (c *SheetsMetadataCmd) Run(ctx context.Context, flags *RootFlags) error {
type SheetsCreateCmd struct {
Title string `arg:"" name:"title" help:"Spreadsheet title"`
Sheets string `name:"sheets" help:"Comma-separated sheet names to create"`
Parent string `name:"parent" help:"Destination folder ID"`
}
func (c *SheetsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -474,9 +476,11 @@ func (c *SheetsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
names := splitCSV(c.Sheets)
parent := normalizeGoogleID(strings.TrimSpace(c.Parent))
if err := dryRunExit(ctx, flags, "sheets.create", map[string]any{
"title": title,
"sheets": names,
"parent": parent,
}); err != nil {
return err
}
@ -513,12 +517,51 @@ func (c *SheetsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
movedToParent := false
moveError := ""
if parent != "" {
parentDriveSvc, driveErr := newDriveService(ctx, account)
if driveErr == nil {
var meta *drive.File
meta, driveErr = parentDriveSvc.Files.Get(resp.SpreadsheetId).
SupportsAllDrives(true).
Fields("id, parents").
Context(ctx).
Do()
if driveErr == nil {
moveCall := parentDriveSvc.Files.Update(resp.SpreadsheetId, &drive.File{}).
AddParents(parent).
SupportsAllDrives(true).
Context(ctx)
if len(meta.Parents) > 0 {
moveCall = moveCall.RemoveParents(strings.Join(meta.Parents, ","))
}
_, driveErr = moveCall.Do()
}
}
if driveErr != nil {
moveError = driveErr.Error()
u.Err().Errorf("failed to move spreadsheet to folder: %v", driveErr)
u.Err().Println("Spreadsheet created in Drive root. Move to desired folder if needed.")
} else {
movedToParent = true
}
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
payload := map[string]any{
"spreadsheetId": resp.SpreadsheetId,
"title": resp.Properties.Title,
"spreadsheetUrl": resp.SpreadsheetUrl,
})
}
if parent != "" {
payload["parent"] = parent
payload["movedToParent"] = movedToParent
if moveError != "" {
payload["moveError"] = moveError
}
}
return outfmt.WriteJSON(ctx, os.Stdout, payload)
}
u.Out().Printf("Created spreadsheet: %s", resp.Properties.Title)

View File

@ -0,0 +1,223 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
func TestSheetsCreateCmd_ParentMoveSuccess(t *testing.T) {
origSheets := newSheetsService
origDrive := newDriveService
t.Cleanup(func() {
newSheetsService = origSheets
newDriveService = origDrive
})
sheetsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v4/spreadsheets") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"spreadsheetId": "id2",
"spreadsheetUrl": "https://example.test/sheets/id2",
"properties": map[string]any{"title": "Budget"},
})
}))
defer sheetsSrv.Close()
var sawGet bool
var sawPatch bool
driveSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/files/id2") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
sawGet = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id2",
"parents": []string{"root"},
})
case http.MethodPatch:
sawPatch = true
if got := r.URL.Query().Get("addParents"); got != "folder123" {
t.Fatalf("addParents=%q", got)
}
if got := r.URL.Query().Get("removeParents"); got != "root" {
t.Fatalf("removeParents=%q", got)
}
if got := r.URL.Query().Get("supportsAllDrives"); got != "true" {
t.Fatalf("supportsAllDrives=%q", got)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id2",
"parents": []string{"folder123"},
})
default:
http.NotFound(w, r)
}
}))
defer driveSrv.Close()
t.Setenv("GOG_ACCOUNT", "a@b.com")
sheetsSvc, err := sheets.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(sheetsSrv.Client()),
option.WithEndpoint(sheetsSrv.URL+"/"),
)
if err != nil {
t.Fatalf("sheets.NewService: %v", err)
}
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(driveSrv.Client()),
option.WithEndpoint(driveSrv.URL+"/"),
)
if err != nil {
t.Fatalf("drive.NewService: %v", err)
}
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return sheetsSvc, nil }
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
var payload map[string]any
stderr := captureStderr(t, func() {
stdout := captureStdout(t, func() {
if err := Execute([]string{"--json", "sheets", "create", "Budget", "--parent", "folder123"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("json.Unmarshal: %v\nstdout=%q", err, stdout)
}
})
if !sawGet || !sawPatch {
t.Fatalf("expected drive get+patch, sawGet=%v sawPatch=%v", sawGet, sawPatch)
}
if got := payload["parent"]; got != "folder123" {
t.Fatalf("parent=%v", got)
}
if got := payload["movedToParent"]; got != true {
t.Fatalf("movedToParent=%v", got)
}
if _, ok := payload["moveError"]; ok {
t.Fatalf("unexpected moveError=%v", payload["moveError"])
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("unexpected stderr=%q", stderr)
}
}
func TestSheetsCreateCmd_ParentMoveFailureReportedInJSON(t *testing.T) {
origSheets := newSheetsService
origDrive := newDriveService
t.Cleanup(func() {
newSheetsService = origSheets
newDriveService = origDrive
})
sheetsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/v4/spreadsheets") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"spreadsheetId": "id2",
"spreadsheetUrl": "https://example.test/sheets/id2",
"properties": map[string]any{"title": "Budget"},
})
}))
defer sheetsSrv.Close()
driveSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/files/id2") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id2",
"parents": []string{"root"},
})
case http.MethodPatch:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": 403,
"message": "forbidden",
},
})
default:
http.NotFound(w, r)
}
}))
defer driveSrv.Close()
t.Setenv("GOG_ACCOUNT", "a@b.com")
sheetsSvc, err := sheets.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(sheetsSrv.Client()),
option.WithEndpoint(sheetsSrv.URL+"/"),
)
if err != nil {
t.Fatalf("sheets.NewService: %v", err)
}
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(driveSrv.Client()),
option.WithEndpoint(driveSrv.URL+"/"),
)
if err != nil {
t.Fatalf("drive.NewService: %v", err)
}
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return sheetsSvc, nil }
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
var payload map[string]any
stderr := captureStderr(t, func() {
stdout := captureStdout(t, func() {
if err := Execute([]string{"--json", "sheets", "create", "Budget", "--parent", "folder123"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("json.Unmarshal: %v\nstdout=%q", err, stdout)
}
})
if got := payload["parent"]; got != "folder123" {
t.Fatalf("parent=%v", got)
}
if got := payload["movedToParent"]; got != false {
t.Fatalf("movedToParent=%v", got)
}
moveError, _ := payload["moveError"].(string)
if !strings.Contains(moveError, "forbidden") {
t.Fatalf("moveError=%q", moveError)
}
if !strings.Contains(stderr, "failed to move spreadsheet to folder") {
t.Fatalf("stderr=%q", stderr)
}
if !strings.Contains(stderr, "Spreadsheet created in Drive root") {
t.Fatalf("stderr=%q", stderr)
}
}