From b47b290c4dc8f1ac28ec81892d6ee55112dca432 Mon Sep 17 00:00:00 2001 From: Omri Ariav Date: Thu, 19 Feb 2026 22:02:38 +0200 Subject: [PATCH] feat(sheets): add chart and conditional formatting commands (#108, #111) Re-apply on top of merged main (includes Agent A's named ranges + filters). Add 6 new sheets commands: - add-chart: create embedded charts (BAR, LINE, AREA, COLUMN, SCATTER, PIE, COMBO) with Series data for both basic and pie charts, chart type validation - list-charts: list all charts with IDs, titles, and types - delete-chart: remove a chart by ID - add-conditional-format: add conditional formatting rules with user-friendly operators - list-conditional-formats: list rules for a sheet - delete-conditional-format: remove a rule by index Total sheets commands: 38 (32 from main + 6 new) Co-Authored-By: Claude Opus 4.6 --- README.md | 6 + cmd/commands_test.go | 6 + cmd/sheets.go | 569 +++++++++++++++++++++++++++ cmd/sheets_test.go | 422 ++++++++++++++++++++ skills/sheets/SKILL.md | 76 +++- skills/sheets/references/commands.md | 193 ++++++++- 6 files changed, 1270 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index abadb5f..6343ebe 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,12 @@ Add `--format text` for human-readable output, or `--format yaml` for YAML. | `gws sheets add-filter ` | Set basic filter on range | | `gws sheets clear-filter ` | Clear basic filter (`--sheet`) | | `gws sheets add-filter-view ` | Add filter view (`--name`) | +| `gws sheets add-chart ` | Add embedded chart (`--type`, `--data`, `--title`, `--sheet`) | +| `gws sheets list-charts ` | List all charts in a spreadsheet | +| `gws sheets delete-chart ` | Delete a chart (`--chart-id`) | +| `gws sheets add-conditional-format ` | Add conditional format rule (`--rule`, `--value`, `--bg-color`, `--bold`) | +| `gws sheets list-conditional-formats ` | List conditional format rules (`--sheet`) | +| `gws sheets delete-conditional-format ` | Delete conditional format rule (`--sheet`, `--index`) | ### Slides diff --git a/cmd/commands_test.go b/cmd/commands_test.go index b31566e..eb51e0c 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -408,6 +408,12 @@ func TestSheetsCommands(t *testing.T) { {"add-filter"}, {"clear-filter"}, {"add-filter-view"}, + {"add-chart"}, + {"list-charts"}, + {"delete-chart"}, + {"add-conditional-format"}, + {"list-conditional-formats"}, + {"delete-conditional-format"}, } for _, tt := range tests { diff --git a/cmd/sheets.go b/cmd/sheets.go index 2d84d76..4c34b41 100644 --- a/cmd/sheets.go +++ b/cmd/sheets.go @@ -343,6 +343,65 @@ var sheetsAddFilterViewCmd = &cobra.Command{ RunE: runSheetsAddFilterView, } +var sheetsAddChartCmd = &cobra.Command{ + Use: "add-chart ", + Short: "Add a chart to a spreadsheet", + Long: "Adds an embedded chart (bar, line, area, column, scatter, pie, combo) to a spreadsheet.", + Args: cobra.ExactArgs(1), + RunE: runSheetsAddChart, +} + +var sheetsListChartsCmd = &cobra.Command{ + Use: "list-charts ", + Short: "List charts in a spreadsheet", + Long: "Lists all embedded charts in a spreadsheet with their IDs, titles, and types.", + Args: cobra.ExactArgs(1), + RunE: runSheetsListCharts, +} + +var sheetsDeleteChartCmd = &cobra.Command{ + Use: "delete-chart ", + Short: "Delete a chart from a spreadsheet", + Long: "Deletes an embedded chart by its chart ID.", + Args: cobra.ExactArgs(1), + RunE: runSheetsDeleteChart, +} + +var sheetsAddConditionalFormatCmd = &cobra.Command{ + Use: "add-conditional-format ", + Short: "Add a conditional formatting rule", + Long: `Adds a conditional formatting rule to a range of cells. + +Rule types: + > Number greater than value + < Number less than value + = Number equal to value + != Number not equal to value + contains Text contains value + not-contains Text does not contain value + blank Cell is blank + not-blank Cell is not blank + formula Custom formula (value is the formula)`, + Args: cobra.ExactArgs(2), + RunE: runSheetsAddConditionalFormat, +} + +var sheetsListConditionalFormatsCmd = &cobra.Command{ + Use: "list-conditional-formats ", + Short: "List conditional formatting rules", + Long: "Lists all conditional formatting rules for a specific sheet.", + Args: cobra.ExactArgs(1), + RunE: runSheetsListConditionalFormats, +} + +var sheetsDeleteConditionalFormatCmd = &cobra.Command{ + Use: "delete-conditional-format ", + Short: "Delete a conditional formatting rule", + Long: "Deletes a conditional formatting rule by its index within a sheet.", + Args: cobra.ExactArgs(1), + RunE: runSheetsDeleteConditionalFormat, +} + func init() { rootCmd.AddCommand(sheetsCmd) sheetsCmd.AddCommand(sheetsInfoCmd) @@ -518,6 +577,45 @@ func init() { sheetsCmd.AddCommand(sheetsAddFilterViewCmd) sheetsAddFilterViewCmd.Flags().String("name", "", "Title for the filter view (required)") sheetsAddFilterViewCmd.MarkFlagRequired("name") + + // Add-chart command + sheetsCmd.AddCommand(sheetsAddChartCmd) + sheetsAddChartCmd.Flags().String("type", "", "Chart type: BAR, LINE, AREA, COLUMN, SCATTER, PIE, COMBO (required)") + sheetsAddChartCmd.Flags().String("data", "", "Data range (e.g., Sheet1!A1:B10) (required)") + sheetsAddChartCmd.Flags().String("title", "", "Chart title") + sheetsAddChartCmd.Flags().String("sheet", "", "Sheet to place chart on (defaults to new sheet)") + sheetsAddChartCmd.MarkFlagRequired("type") + sheetsAddChartCmd.MarkFlagRequired("data") + + // List-charts command + sheetsCmd.AddCommand(sheetsListChartsCmd) + + // Delete-chart command + sheetsCmd.AddCommand(sheetsDeleteChartCmd) + sheetsDeleteChartCmd.Flags().Int64("chart-id", 0, "Chart ID to delete (required)") + sheetsDeleteChartCmd.MarkFlagRequired("chart-id") + + // Add-conditional-format command + sheetsCmd.AddCommand(sheetsAddConditionalFormatCmd) + sheetsAddConditionalFormatCmd.Flags().String("rule", "", "Condition type: >, <, =, !=, contains, not-contains, blank, not-blank, formula (required)") + sheetsAddConditionalFormatCmd.Flags().String("value", "", "Comparison value (required for >, <, =, !=, contains, not-contains, formula)") + sheetsAddConditionalFormatCmd.Flags().String("bg-color", "", "Background color (hex, e.g., #FFFF00)") + sheetsAddConditionalFormatCmd.Flags().String("color", "", "Text color (hex, e.g., #FF0000)") + sheetsAddConditionalFormatCmd.Flags().Bool("bold", false, "Make matching text bold") + sheetsAddConditionalFormatCmd.Flags().Bool("italic", false, "Make matching text italic") + sheetsAddConditionalFormatCmd.MarkFlagRequired("rule") + + // List-conditional-formats command + sheetsCmd.AddCommand(sheetsListConditionalFormatsCmd) + sheetsListConditionalFormatsCmd.Flags().String("sheet", "", "Sheet name (required)") + sheetsListConditionalFormatsCmd.MarkFlagRequired("sheet") + + // Delete-conditional-format command + sheetsCmd.AddCommand(sheetsDeleteConditionalFormatCmd) + sheetsDeleteConditionalFormatCmd.Flags().String("sheet", "", "Sheet name (required)") + sheetsDeleteConditionalFormatCmd.Flags().Int64("index", 0, "0-based index of the rule to delete (required)") + sheetsDeleteConditionalFormatCmd.MarkFlagRequired("sheet") + sheetsDeleteConditionalFormatCmd.MarkFlagRequired("index") } func runSheetsInfo(cmd *cobra.Command, args []string) error { @@ -2475,3 +2573,474 @@ func runSheetsAddFilterView(cmd *cobra.Command, args []string) error { "range": rangeStr, }) } + +func runSheetsAddChart(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + chartType, _ := cmd.Flags().GetString("type") + dataRange, _ := cmd.Flags().GetString("data") + title, _ := cmd.Flags().GetString("title") + sheetName, _ := cmd.Flags().GetString("sheet") + + chartType = strings.ToUpper(chartType) + + validTypes := map[string]bool{"BAR": true, "LINE": true, "AREA": true, "COLUMN": true, "SCATTER": true, "PIE": true, "COMBO": true} + if !validTypes[chartType] { + return p.PrintError(fmt.Errorf("unknown chart type: %s (valid: BAR, LINE, AREA, COLUMN, SCATTER, PIE, COMBO)", chartType)) + } + + _, gridRange, err := parseRange(svc, spreadsheetID, dataRange) + if err != nil { + return p.PrintError(fmt.Errorf("failed to parse data range: %w", err)) + } + + sourceRange := &sheets.ChartSourceRange{Sources: []*sheets.GridRange{gridRange}} + chartData := &sheets.ChartData{SourceRange: sourceRange} + + spec := &sheets.ChartSpec{Title: title} + + if chartType == "PIE" { + spec.PieChart = &sheets.PieChartSpec{ + Domain: chartData, + Series: &sheets.ChartData{SourceRange: sourceRange}, + } + } else { + spec.BasicChart = &sheets.BasicChartSpec{ + ChartType: chartType, + Domains: []*sheets.BasicChartDomain{ + {Domain: chartData}, + }, + Series: []*sheets.BasicChartSeries{ + {Series: &sheets.ChartData{SourceRange: sourceRange}}, + }, + } + } + + position := &sheets.EmbeddedObjectPosition{} + if sheetName != "" { + sheetID, err := getSheetID(svc, spreadsheetID, sheetName) + if err != nil { + return p.PrintError(err) + } + position.OverlayPosition = &sheets.OverlayPosition{ + AnchorCell: &sheets.GridCoordinate{ + SheetId: sheetID, + RowIndex: 0, + ColumnIndex: 0, + }, + } + } else { + position.NewSheet = true + } + + requests := []*sheets.Request{ + { + AddChart: &sheets.AddChartRequest{ + Chart: &sheets.EmbeddedChart{ + Spec: spec, + Position: position, + }, + }, + }, + } + + resp, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: requests, + }).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to add chart: %w", 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 + } + + return p.Print(map[string]interface{}{ + "status": "added", + "spreadsheet": spreadsheetID, + "chart_id": chartID, + "type": chartType, + "title": title, + "data_range": dataRange, + }) +} + +func runSheetsListCharts(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + + spreadsheet, err := svc.Spreadsheets.Get(spreadsheetID).Fields("sheets.charts,sheets.properties").Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get spreadsheet: %w", err)) + } + + var charts []map[string]interface{} + for _, sheet := range spreadsheet.Sheets { + for _, chart := range sheet.Charts { + chartInfo := map[string]interface{}{ + "chart_id": chart.ChartId, + "sheet": sheet.Properties.Title, + "sheet_id": sheet.Properties.SheetId, + } + if chart.Spec != nil { + chartInfo["title"] = chart.Spec.Title + if chart.Spec.BasicChart != nil { + chartInfo["type"] = chart.Spec.BasicChart.ChartType + } else if chart.Spec.PieChart != nil { + chartInfo["type"] = "PIE" + } + } + charts = append(charts, chartInfo) + } + } + + if charts == nil { + charts = []map[string]interface{}{} + } + + return p.Print(map[string]interface{}{ + "spreadsheet": spreadsheetID, + "charts": charts, + "count": len(charts), + }) +} + +func runSheetsDeleteChart(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + chartID, _ := cmd.Flags().GetInt64("chart-id") + + requests := []*sheets.Request{ + { + DeleteEmbeddedObject: &sheets.DeleteEmbeddedObjectRequest{ + ObjectId: chartID, + }, + }, + } + + _, err = svc.Spreadsheets.BatchUpdate(spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: requests, + }).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete chart: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "spreadsheet": spreadsheetID, + "chart_id": chartID, + }) +} + +// mapConditionType maps user-friendly rule names to Sheets API condition types. +func mapConditionType(rule string) (string, error) { + switch rule { + case ">": + return "NUMBER_GREATER", nil + case "<": + return "NUMBER_LESS", nil + case "=": + return "NUMBER_EQ", nil + case "!=": + return "NUMBER_NOT_EQ", nil + case "contains": + return "TEXT_CONTAINS", nil + case "not-contains": + return "TEXT_NOT_CONTAINS", nil + case "blank": + return "BLANK", nil + case "not-blank": + return "NOT_BLANK", nil + case "formula": + return "CUSTOM_FORMULA", nil + default: + return "", fmt.Errorf("unknown rule type: %s (valid: >, <, =, !=, contains, not-contains, blank, not-blank, formula)", rule) + } +} + +func runSheetsAddConditionalFormat(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + rangeStr := args[1] + rule, _ := cmd.Flags().GetString("rule") + value, _ := cmd.Flags().GetString("value") + bgColor, _ := cmd.Flags().GetString("bg-color") + textColor, _ := cmd.Flags().GetString("color") + bold, _ := cmd.Flags().GetBool("bold") + italic, _ := cmd.Flags().GetBool("italic") + + conditionType, err := mapConditionType(rule) + if err != nil { + return p.PrintError(err) + } + + _, gridRange, err := parseRange(svc, spreadsheetID, rangeStr) + if err != nil { + return p.PrintError(err) + } + + conditionValues := []*sheets.ConditionValue{} + if value != "" { + conditionValues = append(conditionValues, &sheets.ConditionValue{UserEnteredValue: value}) + } + + cellFormat := &sheets.CellFormat{} + hasFormat := false + + if bgColor != "" { + color, err := parseSheetsHexColor(bgColor) + if err != nil { + return p.PrintError(err) + } + cellFormat.BackgroundColorStyle = &sheets.ColorStyle{RgbColor: color} + hasFormat = true + } + + if textColor != "" { + color, err := parseSheetsHexColor(textColor) + if err != nil { + return p.PrintError(err) + } + cellFormat.TextFormat = &sheets.TextFormat{} + cellFormat.TextFormat.ForegroundColorStyle = &sheets.ColorStyle{RgbColor: color} + hasFormat = true + } + + if bold || italic { + if cellFormat.TextFormat == nil { + cellFormat.TextFormat = &sheets.TextFormat{} + } + cellFormat.TextFormat.Bold = bold + cellFormat.TextFormat.Italic = italic + hasFormat = true + } + + if !hasFormat { + cellFormat.BackgroundColorStyle = &sheets.ColorStyle{ + RgbColor: &sheets.Color{Red: 1.0, Green: 1.0, Blue: 0.0}, + } + } + + conditionalRule := &sheets.ConditionalFormatRule{ + Ranges: []*sheets.GridRange{gridRange}, + BooleanRule: &sheets.BooleanRule{ + Condition: &sheets.BooleanCondition{ + Type: conditionType, + Values: conditionValues, + }, + Format: cellFormat, + }, + } + + requests := []*sheets.Request{ + { + AddConditionalFormatRule: &sheets.AddConditionalFormatRuleRequest{ + Rule: conditionalRule, + Index: 0, + }, + }, + } + + _, err = svc.Spreadsheets.BatchUpdate(spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: requests, + }).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to add conditional format: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "added", + "spreadsheet": spreadsheetID, + "range": rangeStr, + "condition_type": conditionType, + "value": value, + }) +} + +func runSheetsListConditionalFormats(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + sheetName, _ := cmd.Flags().GetString("sheet") + + spreadsheet, err := svc.Spreadsheets.Get(spreadsheetID).Fields("sheets.conditionalFormats,sheets.properties").Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get spreadsheet: %w", err)) + } + + var rules []map[string]interface{} + found := false + for _, sheet := range spreadsheet.Sheets { + if sheet.Properties.Title == sheetName { + found = true + for i, rule := range sheet.ConditionalFormats { + ruleInfo := map[string]interface{}{ + "index": i, + } + + if len(rule.Ranges) > 0 { + ranges := make([]map[string]interface{}, len(rule.Ranges)) + for j, r := range rule.Ranges { + ranges[j] = map[string]interface{}{ + "sheet_id": r.SheetId, + "start_row": r.StartRowIndex, + "end_row": r.EndRowIndex, + "start_col": r.StartColumnIndex, + "end_col": r.EndColumnIndex, + } + } + ruleInfo["ranges"] = ranges + } + + if rule.BooleanRule != nil { + if rule.BooleanRule.Condition != nil { + condition := map[string]interface{}{ + "type": rule.BooleanRule.Condition.Type, + } + if len(rule.BooleanRule.Condition.Values) > 0 { + values := make([]string, len(rule.BooleanRule.Condition.Values)) + for k, v := range rule.BooleanRule.Condition.Values { + values[k] = v.UserEnteredValue + } + condition["values"] = values + } + ruleInfo["condition"] = condition + } + if rule.BooleanRule.Format != nil { + format := map[string]interface{}{} + if rule.BooleanRule.Format.TextFormat != nil { + format["bold"] = rule.BooleanRule.Format.TextFormat.Bold + format["italic"] = rule.BooleanRule.Format.TextFormat.Italic + } + ruleInfo["format"] = format + } + } + + rules = append(rules, ruleInfo) + } + break + } + } + + if !found { + return p.PrintError(fmt.Errorf("sheet '%s' not found", sheetName)) + } + + if rules == nil { + rules = []map[string]interface{}{} + } + + return p.Print(map[string]interface{}{ + "spreadsheet": spreadsheetID, + "sheet": sheetName, + "rules": rules, + "count": len(rules), + }) +} + +func runSheetsDeleteConditionalFormat(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + sheetName, _ := cmd.Flags().GetString("sheet") + index, _ := cmd.Flags().GetInt64("index") + + sheetID, err := getSheetID(svc, spreadsheetID, sheetName) + if err != nil { + return p.PrintError(err) + } + + requests := []*sheets.Request{ + { + DeleteConditionalFormatRule: &sheets.DeleteConditionalFormatRuleRequest{ + SheetId: sheetID, + Index: index, + }, + }, + } + + _, err = svc.Spreadsheets.BatchUpdate(spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: requests, + }).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete conditional format rule: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "spreadsheet": spreadsheetID, + "sheet": sheetName, + "index": index, + }) +} diff --git a/cmd/sheets_test.go b/cmd/sheets_test.go index 0a97c3c..0179c9d 100644 --- a/cmd/sheets_test.go +++ b/cmd/sheets_test.go @@ -1987,6 +1987,428 @@ func TestSheetsCommands_Structure_NamedRangesAndFilters(t *testing.T) { } } +// TestSheetsAddChartCommand_Flags tests add-chart command flags +func TestSheetsAddChartCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "add-chart") + if cmd == nil { + t.Fatal("add-chart command not found") + } + + expectedFlags := []string{"type", "data", "title", "sheet"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsListChartsCommand tests list-charts command +func TestSheetsListChartsCommand(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "list-charts") + if cmd == nil { + t.Fatal("list-charts command not found") + } + + if cmd.Use != "list-charts " { + t.Errorf("unexpected Use: %s", cmd.Use) + } +} + +// TestSheetsDeleteChartCommand_Flags tests delete-chart command flags +func TestSheetsDeleteChartCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "delete-chart") + if cmd == nil { + t.Fatal("delete-chart command not found") + } + + expectedFlags := []string{"chart-id"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsAddConditionalFormatCommand_Flags tests add-conditional-format command flags +func TestSheetsAddConditionalFormatCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "add-conditional-format") + if cmd == nil { + t.Fatal("add-conditional-format command not found") + } + + expectedFlags := []string{"rule", "value", "bg-color", "color", "bold", "italic"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsListConditionalFormatsCommand_Flags tests list-conditional-formats command flags +func TestSheetsListConditionalFormatsCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "list-conditional-formats") + if cmd == nil { + t.Fatal("list-conditional-formats command not found") + } + + expectedFlags := []string{"sheet"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsDeleteConditionalFormatCommand_Flags tests delete-conditional-format command flags +func TestSheetsDeleteConditionalFormatCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "delete-conditional-format") + if cmd == nil { + t.Fatal("delete-conditional-format command not found") + } + + expectedFlags := []string{"sheet", "index"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestMapConditionType tests the mapConditionType helper +func TestMapConditionType(t *testing.T) { + tests := []struct { + rule string + expected string + wantErr bool + }{ + {">", "NUMBER_GREATER", false}, + {"<", "NUMBER_LESS", false}, + {"=", "NUMBER_EQ", false}, + {"!=", "NUMBER_NOT_EQ", false}, + {"contains", "TEXT_CONTAINS", false}, + {"not-contains", "TEXT_NOT_CONTAINS", false}, + {"blank", "BLANK", false}, + {"not-blank", "NOT_BLANK", false}, + {"formula", "CUSTOM_FORMULA", false}, + {"invalid", "", true}, + } + + for _, tt := range tests { + t.Run(tt.rule, func(t *testing.T) { + got, err := mapConditionType(tt.rule) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, got) + } + }) + } +} + +// TestSheetsAddChart_MockServer tests add-chart API integration +func TestSheetsAddChart_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "sheets": []map[string]interface{}{ + { + "properties": map[string]interface{}{ + "sheetId": 0, + "title": "Sheet1", + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + if r.Method == "POST" && strings.Contains(r.URL.Path, ":batchUpdate") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + + requests := req["requests"].([]interface{}) + if len(requests) > 0 { + addChart := requests[0].(map[string]interface{})["addChart"] + if addChart != nil { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "replies": []map[string]interface{}{ + { + "addChart": map[string]interface{}{ + "chart": map[string]interface{}{ + "chartId": 12345, + }, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + } + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsListCharts_MockServer tests list-charts API integration +func TestSheetsListCharts_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "sheets": []map[string]interface{}{ + { + "properties": map[string]interface{}{ + "sheetId": 0, + "title": "Sheet1", + }, + "charts": []map[string]interface{}{ + { + "chartId": 111, + "spec": map[string]interface{}{ + "title": "My Chart", + "basicChart": map[string]interface{}{ + "chartType": "BAR", + }, + }, + }, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsDeleteChart_MockServer tests delete-chart API integration +func TestSheetsDeleteChart_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.Contains(r.URL.Path, ":batchUpdate") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + + requests := req["requests"].([]interface{}) + if len(requests) > 0 { + deleteObj := requests[0].(map[string]interface{})["deleteEmbeddedObject"] + if deleteObj != nil { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "replies": []map[string]interface{}{{}}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + } + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsAddConditionalFormat_MockServer tests add-conditional-format API integration +func TestSheetsAddConditionalFormat_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "sheets": []map[string]interface{}{ + { + "properties": map[string]interface{}{ + "sheetId": 0, + "title": "Sheet1", + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + if r.Method == "POST" && strings.Contains(r.URL.Path, ":batchUpdate") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + + requests := req["requests"].([]interface{}) + if len(requests) > 0 { + addRule := requests[0].(map[string]interface{})["addConditionalFormatRule"] + if addRule != nil { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "replies": []map[string]interface{}{{}}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + } + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsListConditionalFormats_MockServer tests list-conditional-formats API integration +func TestSheetsListConditionalFormats_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "sheets": []map[string]interface{}{ + { + "properties": map[string]interface{}{ + "sheetId": 0, + "title": "Sheet1", + }, + "conditionalFormats": []map[string]interface{}{ + { + "ranges": []map[string]interface{}{ + { + "sheetId": 0, + "startRowIndex": 0, + "endRowIndex": 10, + "startColumnIndex": 0, + "endColumnIndex": 5, + }, + }, + "booleanRule": map[string]interface{}{ + "condition": map[string]interface{}{ + "type": "NUMBER_GREATER", + "values": []map[string]interface{}{ + {"userEnteredValue": "100"}, + }, + }, + "format": map[string]interface{}{ + "textFormat": map[string]interface{}{ + "bold": true, + }, + }, + }, + }, + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsDeleteConditionalFormat_MockServer tests delete-conditional-format API integration +func TestSheetsDeleteConditionalFormat_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "sheets": []map[string]interface{}{ + { + "properties": map[string]interface{}{ + "sheetId": 0, + "title": "Sheet1", + }, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + if r.Method == "POST" && strings.Contains(r.URL.Path, ":batchUpdate") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + + requests := req["requests"].([]interface{}) + if len(requests) > 0 { + deleteRule := requests[0].(map[string]interface{})["deleteConditionalFormatRule"] + if deleteRule != nil { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "replies": []map[string]interface{}{{}}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + } + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsCommands_Structure_Charts_CondFormat tests that chart and conditional format commands are registered +func TestSheetsCommands_Structure_Charts_CondFormat(t *testing.T) { + commands := []string{ + "add-chart", + "list-charts", + "delete-chart", + "add-conditional-format", + "list-conditional-formats", + "delete-conditional-format", + } + + for _, cmdName := range commands { + t.Run(cmdName, func(t *testing.T) { + cmd := findSubcommand(sheetsCmd, cmdName) + if cmd == nil { + t.Fatalf("command '%s' not found", cmdName) + } + }) + } +} + // TestSheetsFreeze_MockServer tests freeze panes API integration func TestSheetsFreeze_MockServer(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/skills/sheets/SKILL.md b/skills/sheets/SKILL.md index 7b7b48e..e9f75a3 100644 --- a/skills/sheets/SKILL.md +++ b/skills/sheets/SKILL.md @@ -9,7 +9,7 @@ metadata: # Google Sheets (gws sheets) -`gws sheets` provides CLI access to Google Sheets with structured JSON output. This skill has 32 commands covering full spreadsheet management including batch operations, named ranges, and filters. +`gws sheets` provides CLI access to Google Sheets with structured JSON output. This skill has 38 commands covering full spreadsheet management including batch operations, named ranges, filters, charts, and conditional formatting. > **Disclaimer:** `gws` is not the official Google CLI. This is an independent, open-source project not endorsed by or affiliated with Google. @@ -97,6 +97,20 @@ For initial setup, see the `gws-auth` skill. | Set row height | `gws sheets set-row-height --sheet "Sheet1" --row 1 --height 50` | | Freeze panes | `gws sheets freeze --sheet "Sheet1" --rows 1 --cols 1` | +### Charts +| Task | Command | +|------|---------| +| Add a chart | `gws sheets add-chart --type BAR --data "Sheet1!A1:B10"` | +| List charts | `gws sheets list-charts ` | +| Delete a chart | `gws sheets delete-chart --chart-id 12345` | + +### Conditional Formatting +| Task | Command | +|------|---------| +| Add a rule | `gws sheets add-conditional-format "A1:D10" --rule ">" --value "100" --bg-color "#FFFF00"` | +| List rules | `gws sheets list-conditional-formats --sheet "Sheet1"` | +| Delete a rule | `gws sheets delete-conditional-format --sheet "Sheet1" --index 0` | + ## Detailed Usage ### info — Get spreadsheet info @@ -353,6 +367,66 @@ gws sheets add-filter-view --name Filter views are saved views that don't affect other users. +### add-chart — Add a chart + +```bash +gws sheets add-chart <spreadsheet-id> [flags] +``` + +**Flags:** +- `--type string` — Chart type: BAR, LINE, AREA, COLUMN, SCATTER, PIE, COMBO (required) +- `--data string` — Data range, e.g., "Sheet1!A1:B10" (required) +- `--title string` — Chart title +- `--sheet string` — Sheet to place chart on (defaults to new sheet) + +### list-charts — List charts + +```bash +gws sheets list-charts <spreadsheet-id> +``` + +### delete-chart — Delete a chart + +```bash +gws sheets delete-chart <spreadsheet-id> --chart-id <id> +``` + +**Flags:** +- `--chart-id int` — Chart ID to delete (required). Get IDs from `list-charts`. + +### add-conditional-format — Add a conditional formatting rule + +```bash +gws sheets add-conditional-format <spreadsheet-id> <range> [flags] +``` + +**Flags:** +- `--rule string` — Condition type (required): `>`, `<`, `=`, `!=`, `contains`, `not-contains`, `blank`, `not-blank`, `formula` +- `--value string` — Comparison value (required for most rules) +- `--bg-color string` — Background color (hex, e.g., "#FFFF00") +- `--color string` — Text color (hex, e.g., "#FF0000") +- `--bold` — Make matching text bold +- `--italic` — Make matching text italic + +### list-conditional-formats — List conditional formatting rules + +```bash +gws sheets list-conditional-formats <spreadsheet-id> --sheet <name> +``` + +**Flags:** +- `--sheet string` — Sheet name (required) + +### delete-conditional-format — Delete a conditional formatting rule + +```bash +gws sheets delete-conditional-format <spreadsheet-id> --sheet <name> --index <n> +``` + +**Flags:** +- `--sheet string` — Sheet name (required) +- `--index int` — 0-based index of the rule to delete (required). Get indices from `list-conditional-formats`. + ## Output Modes ```bash diff --git a/skills/sheets/references/commands.md b/skills/sheets/references/commands.md index d1047fc..0feec74 100644 --- a/skills/sheets/references/commands.md +++ b/skills/sheets/references/commands.md @@ -1,6 +1,6 @@ # Sheets Commands Reference -Complete flag and option reference for `gws sheets` commands — 32 commands total. +Complete flag and option reference for `gws sheets` commands — 38 commands total. > **Disclaimer:** `gws` is not the official Google CLI. This is an independent, open-source project not endorsed by or affiliated with Google. @@ -756,3 +756,194 @@ gws sheets add-filter-view 1abc123xyz "A1:F50" --name "Q1 Data" - Multiple filter views can exist on the same sheet - The response includes the `filter_view_id` - Unlike basic filters, filter views are per-user and don't change the shared view + +--- + +## gws sheets add-chart + +Adds an embedded chart to a spreadsheet. + +``` +Usage: gws sheets add-chart <spreadsheet-id> [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--type` | string | | Yes | Chart type: `BAR`, `LINE`, `AREA`, `COLUMN`, `SCATTER`, `PIE`, `COMBO` | +| `--data` | string | | Yes | Data range (e.g., `Sheet1!A1:B10`) | +| `--title` | string | | No | Chart title | +| `--sheet` | string | | No | Sheet to place chart on (defaults to new chart sheet) | + +### Examples + +```bash +# Add a bar chart from data in A1:B10 +gws sheets add-chart 1abc123xyz --type BAR --data "Sheet1!A1:B10" --title "Sales" + +# Add a pie chart +gws sheets add-chart 1abc123xyz --type PIE --data "Sheet1!A1:B5" --title "Distribution" + +# Add a line chart overlaid on an existing sheet +gws sheets add-chart 1abc123xyz --type LINE --data "Sheet1!A1:C20" --sheet "Sheet1" +``` + +### Notes + +- PIE charts use a different internal spec than other chart types +- Without `--sheet`, the chart is placed on a new dedicated chart sheet +- With `--sheet`, the chart is overlaid on the specified sheet at position A1 +- Valid types: BAR, LINE, AREA, COLUMN, SCATTER, PIE, COMBO + +--- + +## gws sheets list-charts + +Lists all embedded charts in a spreadsheet. + +``` +Usage: gws sheets list-charts <spreadsheet-id> +``` + +No additional flags. Returns chart IDs, titles, types, and which sheet each chart is on. + +--- + +## gws sheets delete-chart + +Deletes an embedded chart by its chart ID. + +``` +Usage: gws sheets delete-chart <spreadsheet-id> [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--chart-id` | int | | Yes | Chart ID to delete | + +### Examples + +```bash +# Delete chart with ID 12345 +gws sheets delete-chart 1abc123xyz --chart-id 12345 + +# List charts first, then delete +gws sheets list-charts 1abc123xyz +gws sheets delete-chart 1abc123xyz --chart-id <id-from-list> +``` + +--- + +## gws sheets add-conditional-format + +Adds a conditional formatting rule to a range of cells. + +``` +Usage: gws sheets add-conditional-format <spreadsheet-id> <range> [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--rule` | string | | Yes | Condition type (see table below) | +| `--value` | string | | Depends | Comparison value | +| `--bg-color` | string | | No | Background color (hex, e.g., `#FFFF00`) | +| `--color` | string | | No | Text color (hex, e.g., `#FF0000`) | +| `--bold` | bool | false | No | Make matching text bold | +| `--italic` | bool | false | No | Make matching text italic | + +### Rule Types + +| Rule | API Type | Needs `--value`? | +|------|----------|-----------------| +| `>` | NUMBER_GREATER | Yes | +| `<` | NUMBER_LESS | Yes | +| `=` | NUMBER_EQ | Yes | +| `!=` | NUMBER_NOT_EQ | Yes | +| `contains` | TEXT_CONTAINS | Yes | +| `not-contains` | TEXT_NOT_CONTAINS | Yes | +| `blank` | BLANK | No | +| `not-blank` | NOT_BLANK | No | +| `formula` | CUSTOM_FORMULA | Yes (formula string) | + +### Examples + +```bash +# Highlight cells > 100 in yellow +gws sheets add-conditional-format 1abc123xyz "Sheet1!A1:D10" --rule ">" --value "100" --bg-color "#FFFF00" + +# Bold cells containing "URGENT" +gws sheets add-conditional-format 1abc123xyz "Sheet1!A1:A100" --rule "contains" --value "URGENT" --bold + +# Red text for negative numbers +gws sheets add-conditional-format 1abc123xyz "B2:B100" --rule "<" --value "0" --color "#FF0000" + +# Highlight blank cells +gws sheets add-conditional-format 1abc123xyz "Sheet1!A1:D10" --rule "blank" --bg-color "#FFCCCC" + +# Custom formula +gws sheets add-conditional-format 1abc123xyz "A1:A100" --rule "formula" --value "=A1>B1" --bold --italic +``` + +### Notes + +- If no format flags are specified, defaults to yellow background +- Colors must be in hex format: `#RRGGBB` +- New rules are inserted at index 0 (highest priority) +- Unbounded ranges (`A:A`, `1:1`) are not supported + +--- + +## gws sheets list-conditional-formats + +Lists all conditional formatting rules for a specific sheet. + +``` +Usage: gws sheets list-conditional-formats <spreadsheet-id> [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--sheet` | string | | Yes | Sheet name | + +### Examples + +```bash +# List all conditional format rules on Sheet1 +gws sheets list-conditional-formats 1abc123xyz --sheet "Sheet1" +``` + +### Notes + +- Returns rule index, condition type, values, ranges, and format details +- Use the index from the output with `delete-conditional-format` + +--- + +## gws sheets delete-conditional-format + +Deletes a conditional formatting rule by its index within a sheet. + +``` +Usage: gws sheets delete-conditional-format <spreadsheet-id> [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--sheet` | string | | Yes | Sheet name | +| `--index` | int | | Yes | 0-based index of the rule to delete | + +### Examples + +```bash +# Delete the first (highest priority) conditional format rule +gws sheets delete-conditional-format 1abc123xyz --sheet "Sheet1" --index 0 + +# List rules first, then delete by index +gws sheets list-conditional-formats 1abc123xyz --sheet "Sheet1" +gws sheets delete-conditional-format 1abc123xyz --sheet "Sheet1" --index 2 +``` + +### Notes + +- Get rule indices from `list-conditional-formats` +- Indices are 0-based +- Deleting a rule shifts the indices of subsequent rules