diff --git a/CLAUDE.md b/CLAUDE.md index 78ea652..8fc0315 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ go run ./cmd/gws # or go run . ## Current Version -**v1.7.0** - Full Slides API editing operations. Adds 12 new Slides commands: delete-object, delete-text, update-text-style, update-transform, create-table, insert-table-rows, delete-table-row, update-table-cell, update-table-border, update-paragraph-style, update-shape, reorder-slides. Enables complete programmatic control over presentation elements including tables, text styling, transforms, and slide ordering. +**v1.8.0** - Table cell text support. Adds `--table-id`, `--row`, and `--col` flags to `slides add-text` for populating table cells programmatically. ## Roadmap diff --git a/Makefile b/Makefile index 631603c..bbdc058 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PKG := ./cmd/gws BUILD_DIR := ./bin # Version info -VERSION ?= 1.7.0 +VERSION ?= 1.8.0 COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS := -ldflags "-X github.com/omriariav/workspace-cli/cmd.Version=$(VERSION) \ diff --git a/README.md b/README.md index 1696bfa..70b86a7 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Add `--format text` to any command for human-readable output. | `gws slides duplicate-slide ` | Duplicate slide (`--slide-id` or `--slide-number`) | | `gws slides add-shape ` | Add shape (`--slide-id/--slide-number`, `--type`, `--x`, `--y`, `--width`, `--height`) | | `gws slides add-image ` | Add image (`--slide-id/--slide-number`, `--url`, `--x`, `--y`, `--width`) | -| `gws slides add-text ` | Insert text into object (`--object-id`, `--text`, `--at`) | +| `gws slides add-text ` | Insert text into shape or table cell (`--object-id` or `--table-id`/`--row`/`--col`, `--text`, `--at`) | | `gws slides replace-text ` | Find and replace text (`--find`, `--replace`, `--match-case`) | | `gws slides delete-object ` | Delete any page element (`--object-id`) | | `gws slides delete-text ` | Clear text from shape (`--object-id`, `--from`, `--to`) | diff --git a/cmd/slides.go b/cmd/slides.go index e52f785..b71c70e 100644 --- a/cmd/slides.go +++ b/cmd/slides.go @@ -100,9 +100,12 @@ Position and size are in points (PT). The image URL must be publicly accessible. var slidesAddTextCmd = &cobra.Command{ Use: "add-text ", Short: "Add text to an object", - Long: "Inserts text into an existing shape or text box on a slide.", - Args: cobra.ExactArgs(1), - RunE: runSlidesAddText, + Long: `Inserts text into an existing shape, text box, or table cell. + +For shapes/text boxes, use --object-id. +For table cells, use --table-id with --row and --col (0-indexed).`, + Args: cobra.ExactArgs(1), + RunE: runSlidesAddText, } var slidesReplaceTextCmd = &cobra.Command{ @@ -287,10 +290,12 @@ func init() { slidesAddImageCmd.MarkFlagRequired("url") // Add-text flags - slidesAddTextCmd.Flags().String("object-id", "", "Object ID to insert text into (required)") + slidesAddTextCmd.Flags().String("object-id", "", "Object ID to insert text into (required for shapes/text boxes)") + slidesAddTextCmd.Flags().String("table-id", "", "Table object ID (required for table cells, mutually exclusive with --object-id)") + slidesAddTextCmd.Flags().Int("row", -1, "Row index, 0-based (required with --table-id)") + slidesAddTextCmd.Flags().Int("col", -1, "Column index, 0-based (required with --table-id)") slidesAddTextCmd.Flags().String("text", "", "Text to insert (required)") slidesAddTextCmd.Flags().Int("at", 0, "Position to insert at (0 = beginning)") - slidesAddTextCmd.MarkFlagRequired("object-id") slidesAddTextCmd.MarkFlagRequired("text") // Replace-text flags @@ -1250,8 +1255,36 @@ func runSlidesAddImage(cmd *cobra.Command, args []string) error { func runSlidesAddText(cmd *cobra.Command, args []string) error { p := printer.New(os.Stdout, GetFormat()) - ctx := context.Background() + // Parse flags first (before client creation for early validation) + presentationID := args[0] + objectID, _ := cmd.Flags().GetString("object-id") + tableID, _ := cmd.Flags().GetString("table-id") + row, _ := cmd.Flags().GetInt("row") + col, _ := cmd.Flags().GetInt("col") + text, _ := cmd.Flags().GetString("text") + insertionIndex, _ := cmd.Flags().GetInt("at") + + // Validate mutually exclusive flags (fail fast before network calls) + if objectID != "" && tableID != "" { + return p.PrintError(fmt.Errorf("cannot specify both --object-id and --table-id")) + } + if objectID == "" && tableID == "" { + return p.PrintError(fmt.Errorf("must specify either --object-id or --table-id")) + } + + // Validate table cell mode requires row and col + if tableID != "" { + if row < 0 { + return p.PrintError(fmt.Errorf("--row is required when using --table-id (valid values: 0 or greater)")) + } + if col < 0 { + return p.PrintError(fmt.Errorf("--col is required when using --table-id (valid values: 0 or greater)")) + } + } + + // Now create the client after validation passes + ctx := context.Background() factory, err := client.NewFactory(ctx) if err != nil { return p.PrintError(err) @@ -1262,18 +1295,38 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error { return p.PrintError(err) } - presentationID := args[0] - objectID, _ := cmd.Flags().GetString("object-id") - text, _ := cmd.Flags().GetString("text") - insertionIndex, _ := cmd.Flags().GetInt("at") + // Build the InsertText request + insertTextReq := &slides.InsertTextRequest{ + Text: text, + InsertionIndex: int64(insertionIndex), + } + + result := map[string]interface{}{ + "status": "inserted", + "presentation_id": presentationID, + "text_length": len(text), + "position": insertionIndex, + } + + if tableID != "" { + // Table cell mode + insertTextReq.ObjectId = tableID + insertTextReq.CellLocation = &slides.TableCellLocation{ + RowIndex: int64(row), + ColumnIndex: int64(col), + } + result["table_id"] = tableID + result["row"] = row + result["col"] = col + } else { + // Shape/text box mode + insertTextReq.ObjectId = objectID + result["object_id"] = objectID + } requests := []*slides.Request{ { - InsertText: &slides.InsertTextRequest{ - ObjectId: objectID, - Text: text, - InsertionIndex: int64(insertionIndex), - }, + InsertText: insertTextReq, }, } @@ -1284,13 +1337,7 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error { return p.PrintError(fmt.Errorf("failed to add text: %w", err)) } - return p.Print(map[string]interface{}{ - "status": "inserted", - "presentation_id": presentationID, - "object_id": objectID, - "text_length": len(text), - "position": insertionIndex, - }) + return p.Print(result) } func runSlidesReplaceText(cmd *cobra.Command, args []string) error { diff --git a/cmd/slides_test.go b/cmd/slides_test.go index d03860c..ce357fb 100644 --- a/cmd/slides_test.go +++ b/cmd/slides_test.go @@ -3,8 +3,10 @@ package cmd import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -667,7 +669,7 @@ func TestSlidesAddTextCommand_Flags(t *testing.T) { t.Fatal("slides add-text command not found") } - expectedFlags := []string{"object-id", "text", "at"} + expectedFlags := []string{"object-id", "table-id", "row", "col", "text", "at"} for _, flag := range expectedFlags { if cmd.Flags().Lookup(flag) == nil { t.Errorf("expected flag '--%s' not found", flag) @@ -675,6 +677,133 @@ func TestSlidesAddTextCommand_Flags(t *testing.T) { } } +// TestSlidesAddTextCommand_FlagDefaults tests add-text command flag defaults +func TestSlidesAddTextCommand_FlagDefaults(t *testing.T) { + cmd := findSubcommand(slidesCmd, "add-text") + if cmd == nil { + t.Fatal("slides add-text command not found") + } + + // Row and col should default to -1 (sentinel for "not provided") + rowFlag := cmd.Flags().Lookup("row") + if rowFlag.DefValue != "-1" { + t.Errorf("expected --row default '-1', got '%s'", rowFlag.DefValue) + } + + colFlag := cmd.Flags().Lookup("col") + if colFlag.DefValue != "-1" { + t.Errorf("expected --col default '-1', got '%s'", colFlag.DefValue) + } + + // at should default to 0 + atFlag := cmd.Flags().Lookup("at") + if atFlag.DefValue != "0" { + t.Errorf("expected --at default '0', got '%s'", atFlag.DefValue) + } +} + +// TestSlidesAddTextCommand_TextRequired tests that --text is required +func TestSlidesAddTextCommand_TextRequired(t *testing.T) { + cmd := findSubcommand(slidesCmd, "add-text") + if cmd == nil { + t.Fatal("slides add-text command not found") + } + + textFlag := cmd.Flags().Lookup("text") + // Check if flag has required annotation + if ann := textFlag.Annotations; ann != nil { + if _, ok := ann["cobra_annotation_bash_completion_one_required_flag"]; !ok { + // This is fine, required is checked differently + } + } +} + +// TestSlidesAddTextCommand_ValidationErrors tests add-text flag validation via CLI +func TestSlidesAddTextCommand_ValidationErrors(t *testing.T) { + tests := []struct { + name string + flags map[string]string + expectedError string + }{ + { + name: "mutual exclusivity - both object-id and table-id", + flags: map[string]string{ + "object-id": "shape-123", + "table-id": "table-456", + "text": "test", + }, + expectedError: "cannot specify both --object-id and --table-id", + }, + { + name: "missing both object-id and table-id", + flags: map[string]string{ + "text": "test", + }, + expectedError: "must specify either --object-id or --table-id", + }, + { + name: "missing row with table-id", + flags: map[string]string{ + "table-id": "table-456", + "col": "0", + "text": "test", + }, + expectedError: "--row is required when using --table-id (valid values: 0 or greater)", + }, + { + name: "missing col with table-id", + flags: map[string]string{ + "table-id": "table-456", + "row": "0", + "text": "test", + }, + expectedError: "--col is required when using --table-id (valid values: 0 or greater)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := findSubcommand(slidesCmd, "add-text") + if cmd == nil { + t.Fatal("slides add-text command not found") + } + + // Reset flags to defaults before each test + cmd.Flags().Set("object-id", "") + cmd.Flags().Set("table-id", "") + cmd.Flags().Set("row", "-1") + cmd.Flags().Set("col", "-1") + cmd.Flags().Set("text", "") + cmd.Flags().Set("at", "0") + + // Set test flags + for flag, value := range tt.flags { + if err := cmd.Flags().Set(flag, value); err != nil { + t.Fatalf("failed to set flag --%s: %v", flag, err) + } + } + + // Capture os.Stdout since the printer writes directly to it + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Execute the command with a dummy presentation ID + _ = cmd.RunE(cmd, []string{"test-presentation-id"}) + + // Restore stdout and read captured output + w.Close() + os.Stdout = oldStdout + captured, _ := io.ReadAll(r) + output := string(captured) + + if !strings.Contains(output, tt.expectedError) { + t.Errorf("expected output containing %q, got %q", tt.expectedError, output) + } + }) + } +} + // TestSlidesReplaceTextCommand_Flags tests replace-text command flags func TestSlidesReplaceTextCommand_Flags(t *testing.T) { cmd := findSubcommand(slidesCmd, "replace-text") @@ -920,6 +1049,139 @@ func TestSlidesAddText_Success(t *testing.T) { } } +// TestSlidesAddTextToTableCell_Success tests inserting text into a table cell +func TestSlidesAddTextToTableCell_Success(t *testing.T) { + batchUpdateCalled := false + + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/presentations/pres-table-text:batchUpdate": func(w http.ResponseWriter, r *http.Request) { + batchUpdateCalled = true + + var req slides.BatchUpdatePresentationRequest + json.NewDecoder(r.Body).Decode(&req) + + insertText := req.Requests[0].InsertText + if insertText == nil { + t.Error("expected InsertText request") + } else { + if insertText.ObjectId != "table-123" { + t.Errorf("expected object ID 'table-123', got '%s'", insertText.ObjectId) + } + if insertText.Text != "Cell Content" { + t.Errorf("expected text 'Cell Content', got '%s'", insertText.Text) + } + if insertText.CellLocation == nil { + t.Error("expected CellLocation to be set for table cell") + } else { + if insertText.CellLocation.RowIndex != 1 { + t.Errorf("expected row index 1, got %d", insertText.CellLocation.RowIndex) + } + if insertText.CellLocation.ColumnIndex != 2 { + t.Errorf("expected column index 2, got %d", insertText.CellLocation.ColumnIndex) + } + } + } + + json.NewEncoder(w).Encode(&slides.BatchUpdatePresentationResponse{ + PresentationId: "pres-table-text", + Replies: []*slides.Response{{}}, + }) + }, + } + + server := mockSlidesServer(t, handlers) + defer server.Close() + + svc, err := slides.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create slides service: %v", err) + } + + _, err = svc.Presentations.BatchUpdate("pres-table-text", &slides.BatchUpdatePresentationRequest{ + Requests: []*slides.Request{ + { + InsertText: &slides.InsertTextRequest{ + ObjectId: "table-123", + Text: "Cell Content", + CellLocation: &slides.TableCellLocation{ + RowIndex: 1, + ColumnIndex: 2, + }, + InsertionIndex: 0, + }, + }, + }, + }).Do() + if err != nil { + t.Fatalf("failed to insert text into table cell: %v", err) + } + + if !batchUpdateCalled { + t.Error("batchUpdate endpoint was not called") + } +} + +// TestSlidesAddText_ShapeMode_NoCellLocation verifies shape mode doesn't set CellLocation +func TestSlidesAddText_ShapeMode_NoCellLocation(t *testing.T) { + batchUpdateCalled := false + + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/presentations/pres-shape-text:batchUpdate": func(w http.ResponseWriter, r *http.Request) { + batchUpdateCalled = true + + var req slides.BatchUpdatePresentationRequest + json.NewDecoder(r.Body).Decode(&req) + + insertText := req.Requests[0].InsertText + if insertText == nil { + t.Error("expected InsertText request") + } else { + if insertText.ObjectId != "shape-456" { + t.Errorf("expected object ID 'shape-456', got '%s'", insertText.ObjectId) + } + // CellLocation should NOT be set for shape mode + if insertText.CellLocation != nil { + t.Error("CellLocation should be nil for shape mode (backward compatibility)") + } + } + + json.NewEncoder(w).Encode(&slides.BatchUpdatePresentationResponse{ + PresentationId: "pres-shape-text", + Replies: []*slides.Response{{}}, + }) + }, + } + + server := mockSlidesServer(t, handlers) + defer server.Close() + + svc, err := slides.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create slides service: %v", err) + } + + // Shape mode: only ObjectId, no CellLocation + _, err = svc.Presentations.BatchUpdate("pres-shape-text", &slides.BatchUpdatePresentationRequest{ + Requests: []*slides.Request{ + { + InsertText: &slides.InsertTextRequest{ + ObjectId: "shape-456", + Text: "Shape text", + InsertionIndex: 0, + // CellLocation intentionally nil + }, + }, + }, + }).Do() + if err != nil { + t.Fatalf("failed to insert text into shape: %v", err) + } + + if !batchUpdateCalled { + t.Error("batchUpdate endpoint was not called") + } +} + // TestSlidesReplaceText_Success tests find and replace func TestSlidesReplaceText_Success(t *testing.T) { batchUpdateCalled := false diff --git a/skills/slides/SKILL.md b/skills/slides/SKILL.md index a499138..96da6d1 100644 --- a/skills/slides/SKILL.md +++ b/skills/slides/SKILL.md @@ -43,7 +43,8 @@ For initial setup, see the `gws-auth` skill. | Duplicate a slide | `gws slides duplicate-slide --slide-number 2` | | Add a shape | `gws slides add-shape --slide-number 1 --type RECTANGLE` | | Add an image | `gws slides add-image --slide-number 1 --url "https://..."` | -| Add text to object | `gws slides add-text --object-id --text "Hello"` | +| Add text to shape | `gws slides add-text --object-id --text "Hello"` | +| Add text to table cell | `gws slides add-text --table-id --row 0 --col 0 --text "Cell"` | | Find and replace | `gws slides replace-text --find "old" --replace "new"` | | Delete any element | `gws slides delete-object --object-id ` | | Clear text from shape | `gws slides delete-text --object-id ` | @@ -152,18 +153,25 @@ gws slides add-image --url [flags] - `--y float` — Y position in points (default: 100) - `--width float` — Width in points (default: 400; height auto-calculated) -### add-text — Add text to an object +### add-text — Add text to shape or table cell ```bash +# For shapes/text boxes: gws slides add-text --object-id --text [flags] + +# For table cells: +gws slides add-text --table-id --row --col --text [flags] ``` **Flags:** -- `--object-id string` — Object ID to insert text into (required) +- `--object-id string` — Shape/text box ID (mutually exclusive with --table-id) +- `--table-id string` — Table object ID (requires --row and --col) +- `--row int` — Row index, 0-based (required with --table-id) +- `--col int` — Column index, 0-based (required with --table-id) - `--text string` — Text to insert (required) - `--at int` — Position to insert at (0 = beginning) -Get object IDs from `gws slides list ` output. +Get object IDs from `gws slides list ` output. For tables, find elements with `"type": "TABLE"` and use their `objectId` as the `--table-id` value. ### replace-text — Find and replace text @@ -328,48 +336,10 @@ gws slides list --format text # Human-readable text - Positions and sizes are in **points (PT)**: standard slide is 720x405 points - Image URLs must be publicly accessible — Google Slides fetches them server-side - `replace-text` operates across ALL slides — useful for template variable substitution -- `add-text` inserts into an existing object; use `add-shape --type TEXT_BOX` to create a text container first +- `add-text` inserts into shapes/text boxes or table cells; use `add-shape --type TEXT_BOX` to create a text container first - Presentation IDs can be extracted from URLs: `docs.google.com/presentation/d//edit` - For comments on a presentation, use `gws drive comments ` -## Known Limitations - -### Table Cell Text Population - -**Gap**: The `add-text` command cannot insert text into table cells. It only works with shapes and text boxes. - -**Status**: [Issue #54](https://github.com/omriariav/workspace-cli/issues/54) - Feature request open - -**Workaround**: Use the Google Slides API directly with the OAuth token from `~/.config/gws/token.json`: - -```python -import json, os, requests - -# Get token -token = json.load(open(os.path.expanduser("~/.config/gws/token.json")))["access_token"] - -# Build request -url = f"https://slides.googleapis.com/v1/presentations/{PRESENTATION_ID}:batchUpdate" -payload = { - "requests": [{ - "insertText": { - "objectId": "TABLE_ID", - "cellLocation": {"rowIndex": 0, "columnIndex": 0}, - "text": "Cell content", - "insertionIndex": 0 - } - }] -} - -# Send -response = requests.post(url, - headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, - json=payload -) -``` - -For bulk population, batch multiple `insertText` requests in the `requests` array. - ## Learnings See [LEARNINGS.md](./LEARNINGS.md) for session-specific learnings and gotchas discovered during real usage.