diff --git a/CHANGELOG.md b/CHANGELOG.md index c75ef28e..2aae0287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Admin: add Workspace Admin Directory commands for users and groups, including user list/get/create/suspend and group membership list/add/remove. (#403) — thanks @dl-alexandre. - Auth: add `--access-token` / `GOG_ACCESS_TOKEN` for direct access-token auth in headless or CI flows, bypassing stored refresh tokens. (#419) — thanks @mmkal. - Chat: add `chat messages reactions create|list|delete` to manage emoji reactions on messages; `react` and `reaction` are aliases for the reactions command group. (#426) — thanks @fernandopps. +- Sheets: add `sheets chart` subcommand with get/list/create/update/delete for managing embedded charts via `--spec-json` passthrough. (#432) — thanks @andybergon. - Sheets: add named range management (`sheets named-ranges`) and let range-based Sheets commands accept named range names where GridRange-backed operations are needed. (#278) — thanks @TheCrazyLex. - Sheets: add `add-tab`, `rename-tab`, and `delete-tab` commands for managing spreadsheet tabs, with delete dry-run/confirmation guardrails. (#309) — thanks @JulienMalige. - Docs: add `--tab-id` to editing commands so write/update/insert/delete/find-replace can target a specific Google Docs tab. (#330) — thanks @ignacioreyna. diff --git a/README.md b/README.md index 8a77ab4d..e0ad3e9c 100644 --- a/README.md +++ b/README.md @@ -1037,6 +1037,14 @@ gog sheets named-ranges add MyCols 'Sheet1!A:C' gog sheets named-ranges update MyNamedRange --name MyNamedRange2 gog sheets named-ranges delete MyNamedRange2 +# Charts +gog sheets chart list +gog sheets chart get --json > chart.json +gog sheets chart create --spec-json @chart.json +gog sheets chart create --spec-json @chart.json --sheet Sheet1 --anchor E10 +gog sheets chart update --spec-json '{"title":"New Title","basicChart":{"chartType":"PIE"}}' +gog sheets chart delete + # Insert rows/cols gog sheets insert "Sheet1" rows 2 --count 3 gog sheets insert "Sheet1" cols 3 --after diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index c2bffc8e..fbe61645 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -38,6 +38,7 @@ type SheetsCmd struct { Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"` Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"` Export SheetsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"` + Chart SheetsChartCmd `cmd:"" name:"chart" aliases:"charts" help:"Manage spreadsheet charts"` AddTab SheetsAddTabCmd `cmd:"" name:"add-tab" help:"Add a new tab/sheet to a spreadsheet"` RenameTab SheetsRenameTabCmd `cmd:"" name:"rename-tab" help:"Rename a tab/sheet in a spreadsheet"` DeleteTab SheetsDeleteTabCmd `cmd:"" name:"delete-tab" help:"Delete a tab/sheet from a spreadsheet (use --force to skip confirmation)"` diff --git a/internal/cmd/sheets_chart.go b/internal/cmd/sheets_chart.go new file mode 100644 index 00000000..68c44019 --- /dev/null +++ b/internal/cmd/sheets_chart.go @@ -0,0 +1,463 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsChartCmd struct { + List SheetsChartListCmd `cmd:"" default:"withargs" help:"List charts in a spreadsheet"` + Get SheetsChartGetCmd `cmd:"" name:"get" aliases:"show,info" help:"Get full chart definition (spec + position)"` + Create SheetsChartCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a chart from a JSON spec"` + Update SheetsChartUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a chart spec"` + Delete SheetsChartDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove,del" help:"Delete a chart"` +} + +// ---------- list ---------- + +type SheetsChartListCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` +} + +func (c *SheetsChartListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(charts(chartId,spec(title,basicChart(chartType)),position(overlayPosition(anchorCell))),properties(sheetId,title))"). + Do() + if err != nil { + return err + } + + type chartItem struct { + ChartID int64 `json:"chartId"` + Title string `json:"title"` + Type string `json:"type"` + SheetID int64 `json:"sheetId"` + SheetTitle string `json:"sheetTitle"` + } + + var items []chartItem + for _, sheet := range resp.Sheets { + sheetTitle := "" + var sheetID int64 + if sheet.Properties != nil { + sheetTitle = sheet.Properties.Title + sheetID = sheet.Properties.SheetId + } + for _, ch := range sheet.Charts { + if ch == nil { + continue + } + it := chartItem{ + ChartID: ch.ChartId, + SheetID: sheetID, + SheetTitle: sheetTitle, + } + if ch.Spec != nil { + it.Title = ch.Spec.Title + if ch.Spec.BasicChart != nil { + it.Type = ch.Spec.BasicChart.ChartType + } + } + items = append(items, it) + } + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"charts": items}) + } + + if len(items) == 0 { + u.Err().Println("No charts") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "CHART_ID\tTITLE\tTYPE\tSHEET_ID\tSHEET_TITLE") + for _, it := range items { + fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%s\n", + it.ChartID, it.Title, it.Type, it.SheetID, it.SheetTitle, + ) + } + return nil +} + +// ---------- get ---------- + +type SheetsChartGetCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + ChartID int64 `arg:"" name:"chartId" help:"Chart ID"` +} + +func (c *SheetsChartGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(charts,properties(sheetId,title))"). + Do() + if err != nil { + return err + } + + for _, sheet := range resp.Sheets { + for _, ch := range sheet.Charts { + if ch == nil || ch.ChartId != c.ChartID { + continue + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, ch) + } + + // Text mode: print key fields. + title := "" + chartType := "" + if ch.Spec != nil { + title = ch.Spec.Title + if ch.Spec.BasicChart != nil { + chartType = ch.Spec.BasicChart.ChartType + } + } + sheetTitle := "" + if sheet.Properties != nil { + sheetTitle = sheet.Properties.Title + } + u.Out().Printf("chartId\t%d", ch.ChartId) + u.Out().Printf("title\t%s", title) + u.Out().Printf("type\t%s", chartType) + u.Out().Printf("sheet\t%s", sheetTitle) + return nil + } + } + + return usagef("chart %d not found", c.ChartID) +} + +// ---------- create ---------- + +type SheetsChartCreateCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + SpecJSON string `name:"spec-json" required:"" help:"EmbeddedChart JSON (inline or @file)"` + Sheet string `name:"sheet" help:"Sheet name for anchor (resolved to sheetId)"` + Anchor string `name:"anchor" help:"Anchor cell in A1 notation (e.g. A1, E10)"` + Width int64 `name:"width" help:"Chart width in pixels" default:"600"` + Height int64 `name:"height" help:"Chart height in pixels" default:"371"` +} + +func (c *SheetsChartCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + specBytes, err := resolveInlineOrFileBytes(c.SpecJSON) + if err != nil { + return fmt.Errorf("read --spec-json: %w", err) + } + if len(specBytes) == 0 { + return usage("empty --spec-json") + } + + var chart sheets.EmbeddedChart + if err := json.Unmarshal(specBytes, &chart); err != nil { + return fmt.Errorf("invalid --spec-json: %w", err) + } + + if err := dryRunExit(ctx, flags, "sheets.chart.create", map[string]any{ + "spreadsheet_id": spreadsheetID, + "sheet": c.Sheet, + "anchor": c.Anchor, + "width": c.Width, + "height": c.Height, + }); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + // Resolve sheet name → ID for the anchor position. + if c.Sheet != "" || c.Anchor != "" { + pos, posErr := buildChartPosition(ctx, svc, spreadsheetID, c.Sheet, c.Anchor, c.Width, c.Height) + if posErr != nil { + return posErr + } + chart.Position = pos + } + + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + {AddChart: &sheets.AddChartRequest{Chart: &chart}}, + }, + } + + resp, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do() + if err != nil { + return err + } + + var chartID int64 + if len(resp.Replies) > 0 && resp.Replies[0].AddChart != nil && resp.Replies[0].AddChart.Chart != nil { + chartID = resp.Replies[0].AddChart.Chart.ChartId + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "chartId": chartID, + }) + } + + u.Out().Printf("Created chart %d in spreadsheet %s", chartID, spreadsheetID) + return nil +} + +// ---------- update ---------- + +type SheetsChartUpdateCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + ChartID int64 `arg:"" name:"chartId" help:"Chart ID to update"` + SpecJSON string `name:"spec-json" required:"" help:"ChartSpec JSON (inline or @file)"` +} + +func (c *SheetsChartUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + specBytes, err := resolveInlineOrFileBytes(c.SpecJSON) + if err != nil { + return fmt.Errorf("read --spec-json: %w", err) + } + if len(specBytes) == 0 { + return usage("empty --spec-json") + } + + var spec sheets.ChartSpec + if err := json.Unmarshal(specBytes, &spec); err != nil { + return fmt.Errorf("invalid --spec-json: %w", err) + } + + if err := dryRunExit(ctx, flags, "sheets.chart.update", map[string]any{ + "spreadsheet_id": spreadsheetID, + "chart_id": c.ChartID, + }); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + UpdateChartSpec: &sheets.UpdateChartSpecRequest{ + ChartId: c.ChartID, + Spec: &spec, + }, + }, + }, + } + + if _, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do(); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "chartId": c.ChartID, + }) + } + + u.Out().Printf("Updated chart %d in spreadsheet %s", c.ChartID, spreadsheetID) + return nil +} + +// ---------- delete ---------- + +type SheetsChartDeleteCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + ChartID int64 `arg:"" name:"chartId" help:"Chart ID to delete"` +} + +func (c *SheetsChartDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + if err := dryRunAndConfirmDestructive(ctx, flags, "sheets.chart.delete", map[string]any{ + "spreadsheet_id": spreadsheetID, + "chart_id": c.ChartID, + }, "delete chart "+strconv.FormatInt(c.ChartID, 10)); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + DeleteEmbeddedObject: &sheets.DeleteEmbeddedObjectRequest{ + ObjectId: c.ChartID, + }, + }, + }, + } + + if _, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do(); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "chartId": c.ChartID, + }) + } + + u.Out().Printf("Deleted chart %d from spreadsheet %s", c.ChartID, spreadsheetID) + return nil +} + +// ---------- helpers ---------- + +// buildChartPosition resolves a sheet name and anchor cell into an EmbeddedObjectPosition. +func buildChartPosition(ctx context.Context, svc *sheets.Service, spreadsheetID, sheetName, anchor string, width, height int64) (*sheets.EmbeddedObjectPosition, error) { + var sheetID int64 + if sheetName != "" { + sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID) + if err != nil { + return nil, err + } + id, ok := sheetIDs[sheetName] + if !ok { + return nil, usagef("unknown sheet %q", sheetName) + } + sheetID = id + } + + var rowIndex, colIndex int64 + if anchor != "" { + parsed, err := parseA1Cell(anchor) + if err != nil { + return nil, fmt.Errorf("invalid --anchor %q: %w", anchor, err) + } + rowIndex = int64(parsed.row - 1) // 0-indexed + colIndex = int64(parsed.col - 1) + } + + return &sheets.EmbeddedObjectPosition{ + OverlayPosition: &sheets.OverlayPosition{ + AnchorCell: &sheets.GridCoordinate{ + SheetId: sheetID, + RowIndex: rowIndex, + ColumnIndex: colIndex, + }, + WidthPixels: width, + HeightPixels: height, + }, + }, nil +} + +// a1Cell holds a parsed single cell reference (row and column are 1-indexed). +type a1Cell struct { + row int + col int +} + +// parseA1Cell parses a simple cell reference like "A1" or "E10" (no sheet prefix). +func parseA1Cell(cell string) (a1Cell, error) { + cell = strings.TrimSpace(cell) + if cell == "" { + return a1Cell{}, fmt.Errorf("empty cell reference") + } + + // Split letters and digits. + i := 0 + for i < len(cell) && ((cell[i] >= 'A' && cell[i] <= 'Z') || (cell[i] >= 'a' && cell[i] <= 'z')) { + i++ + } + if i == 0 || i == len(cell) { + return a1Cell{}, fmt.Errorf("invalid cell reference %q", cell) + } + + col, err := colLettersToIndex(strings.ToUpper(cell[:i])) + if err != nil { + return a1Cell{}, err + } + row, err := strconv.Atoi(cell[i:]) + if err != nil || row < 1 { + return a1Cell{}, fmt.Errorf("invalid row in cell reference %q", cell) + } + return a1Cell{row: row, col: col}, nil +} diff --git a/internal/cmd/sheets_chart_test.go b/internal/cmd/sheets_chart_test.go new file mode 100644 index 00000000..d8b03532 --- /dev/null +++ b/internal/cmd/sheets_chart_test.go @@ -0,0 +1,517 @@ +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/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// chartRecorder captures batchUpdate requests. +type chartRecorder struct { + requests []map[string]any +} + +func chartHandler(recorder *chartRecorder) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + + // Metadata GET for chart list and sheet ID resolution. + if strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet && !strings.Contains(path, "batchUpdate") { + 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": 0, + "title": "Sheet1", + }, + "charts": []map[string]any{ + { + "chartId": 100, + "spec": map[string]any{ + "title": "Revenue", + "basicChart": map[string]any{ + "chartType": "COLUMN", + }, + }, + }, + { + "chartId": 200, + "spec": map[string]any{ + "title": "Expenses", + "basicChart": map[string]any{ + "chartType": "LINE", + }, + }, + }, + }, + }, + }, + }) + return + } + + // BatchUpdate POST. + if strings.HasPrefix(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + requests, ok := body["requests"].([]any) + if !ok || len(requests) == 0 { + http.Error(w, "missing requests", http.StatusBadRequest) + return + } + + recorder.requests = recorder.requests[:0] + for _, req := range requests { + reqMap, ok := req.(map[string]any) + if !ok { + http.Error(w, "expected request object", http.StatusBadRequest) + return + } + recorder.requests = append(recorder.requests, reqMap) + } + + // Build reply for addChart. + replies := make([]map[string]any, len(requests)) + for i, req := range requests { + reqMap, _ := req.(map[string]any) + if _, ok := reqMap["addChart"]; ok { + replies[i] = map[string]any{ + "addChart": map[string]any{ + "chart": map[string]any{ + "chartId": 999, + }, + }, + } + } else { + replies[i] = map[string]any{} + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "replies": replies, + }) + return + } + + http.NotFound(w, r) + }) +} + +func newChartTestContext(t *testing.T, recorder *chartRecorder) (context.Context, *RootFlags, func()) { + t.Helper() + + origNew := newSheetsService + srv := httptest.NewServer(chartHandler(recorder)) + + 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) + + cleanup := func() { + srv.Close() + newSheetsService = origNew + } + return ctx, flags, cleanup +} + +func TestSheetsChartList_JSON(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsChartListCmd{}, []string{"s1"}, ctx, flags); err != nil { + t.Fatalf("chart list: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + charts, ok := result["charts"].([]any) + if !ok { + t.Fatalf("expected charts array, got %T", result["charts"]) + } + if len(charts) != 2 { + t.Fatalf("expected 2 charts, got %d", len(charts)) + } + + first := charts[0].(map[string]any) + if first["chartId"] != float64(100) { + t.Errorf("expected chartId 100, got %v", first["chartId"]) + } + if first["title"] != "Revenue" { + t.Errorf("expected title Revenue, got %v", first["title"]) + } + if first["type"] != "COLUMN" { + t.Errorf("expected type COLUMN, got %v", first["type"]) + } +} + +func TestSheetsChartList_Text(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + out := captureStdout(t, func() { + 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(ctx, u) + + if err := runKong(t, &SheetsChartListCmd{}, []string{"s1"}, ctx, flags); err != nil { + t.Fatalf("chart list: %v", err) + } + }) + + // Text output goes through tableWriter which writes to stdout via the writer. + // Just ensure no error occurred; the test server returns charts. + _ = out +} + +func TestSheetsChartGet_JSON(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsChartGetCmd{}, []string{"s1", "100"}, ctx, flags); err != nil { + t.Fatalf("chart get: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + if result["chartId"] != float64(100) { + t.Errorf("expected chartId 100, got %v", result["chartId"]) + } + + spec, ok := result["spec"].(map[string]any) + if !ok { + t.Fatalf("expected spec in output, got %v", result) + } + if spec["title"] != "Revenue" { + t.Errorf("expected title Revenue, got %v", spec["title"]) + } +} + +func TestSheetsChartGet_NotFound(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + err := runKong(t, &SheetsChartGetCmd{}, []string{"s1", "999999"}, ctx, flags) + if err == nil { + t.Fatal("expected error for unknown chart") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSheetsChartCreate_JSON(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + specJSON := `{"spec":{"title":"Test Chart","basicChart":{"chartType":"BAR"}}}` + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsChartCreateCmd{}, []string{ + "s1", "--spec-json", specJSON, + }, ctx, flags); err != nil { + t.Fatalf("chart create: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + if result["chartId"] != float64(999) { + t.Errorf("expected chartId 999, got %v", result["chartId"]) + } + + if len(recorder.requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(recorder.requests)) + } + if _, ok := recorder.requests[0]["addChart"]; !ok { + t.Fatalf("expected addChart request, got %v", recorder.requests[0]) + } +} + +func TestSheetsChartCreate_WithAnchor(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + specJSON := `{"spec":{"title":"Anchored Chart","basicChart":{"chartType":"LINE"}}}` + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsChartCreateCmd{}, []string{ + "s1", "--spec-json", specJSON, "--sheet", "Sheet1", "--anchor", "E10", + }, ctx, flags); err != nil { + t.Fatalf("chart create: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + if len(recorder.requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(recorder.requests)) + } + + addChart, ok := recorder.requests[0]["addChart"].(map[string]any) + if !ok { + t.Fatalf("expected addChart, got %v", recorder.requests[0]) + } + + chart, ok := addChart["chart"].(map[string]any) + if !ok { + t.Fatalf("expected chart in addChart, got %v", addChart) + } + + pos, ok := chart["position"].(map[string]any) + if !ok { + t.Fatalf("expected position, got %v", chart) + } + + overlay, ok := pos["overlayPosition"].(map[string]any) + if !ok { + t.Fatalf("expected overlayPosition, got %v", pos) + } + + anchor, ok := overlay["anchorCell"].(map[string]any) + if !ok { + t.Fatalf("expected anchorCell, got %v", overlay) + } + + // E10 → row 9 (0-indexed), col 4 (0-indexed) + if anchor["rowIndex"] != float64(9) { + t.Errorf("expected rowIndex 9, got %v", anchor["rowIndex"]) + } + if anchor["columnIndex"] != float64(4) { + t.Errorf("expected columnIndex 4, got %v", anchor["columnIndex"]) + } +} + +func TestSheetsChartUpdate_JSON(t *testing.T) { + recorder := &chartRecorder{} + ctx, flags, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + specJSON := `{"title":"Updated Title","basicChart":{"chartType":"PIE"}}` + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsChartUpdateCmd{}, []string{ + "s1", "100", "--spec-json", specJSON, + }, ctx, flags); err != nil { + t.Fatalf("chart update: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + if result["chartId"] != float64(100) { + t.Errorf("expected chartId 100, got %v", result["chartId"]) + } + + if len(recorder.requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(recorder.requests)) + } + + updateSpec, ok := recorder.requests[0]["updateChartSpec"].(map[string]any) + if !ok { + t.Fatalf("expected updateChartSpec request, got %v", recorder.requests[0]) + } + if updateSpec["chartId"] != float64(100) { + t.Errorf("expected chartId 100 in request, got %v", updateSpec["chartId"]) + } +} + +func TestSheetsChartDelete_JSON(t *testing.T) { + recorder := &chartRecorder{} + ctx, _, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + flagsForce := &RootFlags{Account: "a@b.com", Force: true} + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsChartDeleteCmd{}, []string{"s1", "100"}, ctx, flagsForce); err != nil { + t.Fatalf("chart delete: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + if result["chartId"] != float64(100) { + t.Errorf("expected chartId 100, got %v", result["chartId"]) + } + + if len(recorder.requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(recorder.requests)) + } + + delReq, ok := recorder.requests[0]["deleteEmbeddedObject"].(map[string]any) + if !ok { + t.Fatalf("expected deleteEmbeddedObject request, got %v", recorder.requests[0]) + } + if delReq["objectId"] != float64(100) { + t.Errorf("expected objectId 100, got %v", delReq["objectId"]) + } +} + +func TestSheetsChartDelete_RequiresConfirmation(t *testing.T) { + recorder := &chartRecorder{} + ctx, _, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + // NoInput + no Force → should refuse. + flags := &RootFlags{Account: "a@b.com", NoInput: true} + + err := runKong(t, &SheetsChartDeleteCmd{}, []string{"s1", "100"}, ctx, flags) + if err == nil { + t.Fatal("expected error without --force") + } + if !strings.Contains(err.Error(), "without --force") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSheetsChartDelete_DryRun(t *testing.T) { + recorder := &chartRecorder{} + ctx, _, cleanup := newChartTestContext(t, recorder) + defer cleanup() + + flags := &RootFlags{Account: "a@b.com", DryRun: true, NoInput: true} + + err := runKong(t, &SheetsChartDeleteCmd{}, []string{"s1", "100"}, ctx, flags) + if ExitCode(err) != 0 { + t.Fatalf("expected dry-run exit 0, got %v", err) + } + if len(recorder.requests) != 0 { + t.Fatalf("expected no mutation during dry-run, got %d requests", len(recorder.requests)) + } +} + +func TestSheetsChartCreate_EmptySpreadsheetID(t *testing.T) { + ctx, _, cleanup := newChartTestContext(t, &chartRecorder{}) + defer cleanup() + + err := runKong(t, &SheetsChartCreateCmd{}, []string{"", "--spec-json", `{}`}, ctx, &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error for empty spreadsheetId") + } + if !strings.Contains(err.Error(), "empty spreadsheetId") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestSheetsChartCreate_InvalidSpecJSON(t *testing.T) { + ctx, _, cleanup := newChartTestContext(t, &chartRecorder{}) + defer cleanup() + + err := runKong(t, &SheetsChartCreateCmd{}, []string{"s1", "--spec-json", "not json"}, ctx, &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "invalid --spec-json") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseA1Cell(t *testing.T) { + tests := []struct { + input string + wantRow int + wantCol int + wantErr bool + }{ + {"A1", 1, 1, false}, + {"B5", 5, 2, false}, + {"Z26", 26, 26, false}, + {"AA1", 1, 27, false}, + {"E10", 10, 5, false}, + {"", 0, 0, true}, + {"1A", 0, 0, true}, + {"A", 0, 0, true}, + {"A0", 0, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseA1Cell(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q", tt.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.input, err) + } + if got.row != tt.wantRow || got.col != tt.wantCol { + t.Errorf("parseA1Cell(%q) = {row:%d col:%d}, want {row:%d col:%d}", tt.input, got.row, got.col, tt.wantRow, tt.wantCol) + } + }) + } +}