diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index e553dc08..1a755a0b 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -27,6 +27,7 @@ type SheetsCmd struct { Get SheetsGetCmd `cmd:"" name:"get" help:"Get values from a range"` Update SheetsUpdateCmd `cmd:"" name:"update" help:"Update values in a range"` Append SheetsAppendCmd `cmd:"" name:"append" help:"Append values to a range"` + Insert SheetsInsertCmd `cmd:"" name:"insert" help:"Insert empty rows or columns into a sheet"` Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"` Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"` Metadata SheetsMetadataCmd `cmd:"" name:"metadata" help:"Get spreadsheet metadata"` diff --git a/internal/cmd/sheets_insert.go b/internal/cmd/sheets_insert.go new file mode 100644 index 00000000..569bc3ba --- /dev/null +++ b/internal/cmd/sheets_insert.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsInsertCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Sheet string `arg:"" name:"sheet" help:"Sheet name (eg. Sheet1)"` + Dimension string `arg:"" name:"dimension" help:"Dimension to insert: rows or cols"` + Start int64 `arg:"" name:"start" help:"Position before which to insert (1-based; for cols 1=A, 2=B)"` + Count int64 `name:"count" help:"Number of rows/columns to insert" default:"1"` + After bool `name:"after" help:"Insert after the position instead of before"` +} + +func (c *SheetsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := strings.TrimSpace(c.SpreadsheetID) + sheetName := strings.TrimSpace(c.Sheet) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if sheetName == "" { + return usage("empty sheet name") + } + + dim := strings.ToLower(strings.TrimSpace(c.Dimension)) + var apiDimension, dimLabel string + switch dim { + case "rows", "row": + apiDimension = "ROWS" + dimLabel = "row" + case "cols", "col", "columns", "column": + apiDimension = "COLUMNS" + dimLabel = "column" + default: + return fmt.Errorf("dimension must be rows or cols, got %q", c.Dimension) + } + + if c.Start < 1 { + return fmt.Errorf("start must be >= 1") + } + if c.Count < 1 { + return fmt.Errorf("count must be >= 1") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID) + if err != nil { + return err + } + sheetID, ok := sheetIDs[sheetName] + if !ok { + return fmt.Errorf("unknown sheet %q", sheetName) + } + + // Convert 1-based position to 0-based index for the API. + startIndex := c.Start - 1 + if c.After { + startIndex = c.Start + } + endIndex := startIndex + c.Count + + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + InsertDimension: &sheets.InsertDimensionRequest{ + Range: &sheets.DimensionRange{ + SheetId: sheetID, + Dimension: apiDimension, + StartIndex: startIndex, + EndIndex: endIndex, + }, + InheritFromBefore: c.After, + }, + }, + }, + } + + if _, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do(); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "sheet": sheetName, + "dimension": apiDimension, + "start": c.Start, + "count": c.Count, + "after": c.After, + "startIndex": startIndex, + "endIndex": endIndex, + }) + } + + position := "before" + if c.After { + position = "after" + } + plural := dimLabel + "s" + if c.Count == 1 { + plural = dimLabel + } + u.Out().Printf("Inserted %d %s %s %s %d in %q", c.Count, plural, position, dimLabel, c.Start, sheetName) + return nil +} diff --git a/internal/cmd/sheets_insert_test.go b/internal/cmd/sheets_insert_test.go new file mode 100644 index 00000000..5872b8c7 --- /dev/null +++ b/internal/cmd/sheets_insert_test.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/ui" +) + +func TestSheetsInsertCmd(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var gotInsert *sheets.InsertDimensionRequest + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + switch { + case strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "sheets": []map[string]any{ + {"properties": map[string]any{"sheetId": 7, "title": "Data"}}, + }, + }) + return + case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost: + var req sheets.BatchUpdateSpreadsheetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + if len(req.Requests) != 1 || req.Requests[0].InsertDimension == nil { + t.Fatalf("expected insertDimension request, got %#v", req.Requests) + } + gotInsert = req.Requests[0].InsertDimension + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + t.Run("insert rows before", func(t *testing.T) { + gotInsert = nil + cmd := &SheetsInsertCmd{} + if err := runKong(t, cmd, []string{ + "s1", "Data", "rows", "2", "--count", "3", + }, ctx, flags); err != nil { + t.Fatalf("insert rows: %v", err) + } + + if gotInsert == nil { + t.Fatal("expected insertDimension request") + } + if gotInsert.Range.SheetId != 7 { + t.Fatalf("unexpected sheet id: %d", gotInsert.Range.SheetId) + } + if gotInsert.Range.Dimension != "ROWS" { + t.Fatalf("unexpected dimension: %s", gotInsert.Range.Dimension) + } + // startRow=2 → startIndex=1, endIndex=1+3=4 + if gotInsert.Range.StartIndex != 1 { + t.Fatalf("unexpected startIndex: %d, want 1", gotInsert.Range.StartIndex) + } + if gotInsert.Range.EndIndex != 4 { + t.Fatalf("unexpected endIndex: %d, want 4", gotInsert.Range.EndIndex) + } + if gotInsert.InheritFromBefore { + t.Fatal("expected inheritFromBefore=false") + } + }) + + t.Run("insert rows after", func(t *testing.T) { + gotInsert = nil + cmd := &SheetsInsertCmd{} + if err := runKong(t, cmd, []string{ + "s1", "Data", "rows", "2", "--count", "1", "--after", + }, ctx, flags); err != nil { + t.Fatalf("insert rows: %v", err) + } + + if gotInsert == nil { + t.Fatal("expected insertDimension request") + } + // startRow=2 --after → startIndex=2, endIndex=3 + if gotInsert.Range.StartIndex != 2 { + t.Fatalf("unexpected startIndex: %d, want 2", gotInsert.Range.StartIndex) + } + if gotInsert.Range.EndIndex != 3 { + t.Fatalf("unexpected endIndex: %d, want 3", gotInsert.Range.EndIndex) + } + if !gotInsert.InheritFromBefore { + t.Fatal("expected inheritFromBefore=true") + } + }) + + t.Run("insert cols before", func(t *testing.T) { + gotInsert = nil + cmd := &SheetsInsertCmd{} + if err := runKong(t, cmd, []string{ + "s1", "Data", "cols", "3", "--count", "2", + }, ctx, flags); err != nil { + t.Fatalf("insert cols: %v", err) + } + + if gotInsert == nil { + t.Fatal("expected insertDimension request") + } + if gotInsert.Range.Dimension != "COLUMNS" { + t.Fatalf("unexpected dimension: %s", gotInsert.Range.Dimension) + } + // startCol=3 → startIndex=2, endIndex=2+2=4 + if gotInsert.Range.StartIndex != 2 { + t.Fatalf("unexpected startIndex: %d, want 2", gotInsert.Range.StartIndex) + } + if gotInsert.Range.EndIndex != 4 { + t.Fatalf("unexpected endIndex: %d, want 4", gotInsert.Range.EndIndex) + } + }) + + t.Run("invalid dimension", func(t *testing.T) { + cmd := &SheetsInsertCmd{} + err := runKong(t, cmd, []string{ + "s1", "Data", "sheets", "1", + }, ctx, flags) + if err == nil { + t.Fatal("expected error for invalid dimension") + } + if !strings.Contains(err.Error(), "dimension must be rows or cols") { + t.Fatalf("unexpected error: %v", err) + } + }) +}