diff --git a/README.md b/README.md index 19d9f10..a2f2703 100644 --- a/README.md +++ b/README.md @@ -1055,6 +1055,11 @@ gog slides list-slides gog slides add-slide ./slide.png --notes "Speaker notes" gog slides update-notes --notes "Updated notes" gog slides replace-slide ./new-slide.png --notes "New notes" +gog slides insert-text "Text to insert" +gog slides insert-text - < long-content.md +gog slides insert-text "New body" --replace +gog slides replace-text "{{name}}" "Acme Corp" +gog slides replace-text "TODO" "DONE" --match-case --page --page # Sheets gog sheets copy "My Sheet Copy" @@ -1474,6 +1479,32 @@ gog slides thumbnail --output ./slide.png # Control thumbnail size and format gog slides thumbnail --size medium --format jpeg --output ./slide.jpg + +# Insert text into an existing text-capable element (shape or table cell) +gog slides insert-text "Hello, world" + +# Insert at a specific position in the element's existing text +gog slides insert-text " (inserted)" --insertion-index 12 + +# Replace the element's existing text wholesale (DeleteText + InsertText in one batch) +gog slides insert-text "Brand-new body copy" --replace + +# Read long content from stdin +cat long-content.md | gog slides insert-text - + +# Preview the batchUpdate request body without executing it +gog slides insert-text "demo" --replace --dry-run + +# Find-and-replace across the whole deck +gog slides replace-text "{{customer_name}}" "Acme Corp" + +# Case-sensitive match, restricted to specific slides +gog slides replace-text "TODO" "DONE" \ + --match-case \ + --page --page + +# Preview the replace request without executing it +gog slides replace-text "old" "new" --dry-run ``` ## Output Formats diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index f6d0088..e2d8873 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -33,6 +33,8 @@ type SlidesCmd struct { Thumbnail SlidesThumbnailCmd `cmd:"" name:"thumbnail" aliases:"thumb" help:"Get or download a rendered thumbnail for a slide"` UpdateNotes SlidesUpdateNotesCmd `cmd:"" name:"update-notes" help:"Update speaker notes on an existing slide"` ReplaceSlide SlidesReplaceSlideCmd `cmd:"" name:"replace-slide" help:"Replace the image on an existing slide in-place"` + InsertText SlidesInsertTextCmd `cmd:"" name:"insert-text" help:"Insert text into an existing page element (shape or table) by objectId"` + ReplaceText SlidesReplaceTextCmd `cmd:"" name:"replace-text" help:"Find-and-replace text across a presentation"` } type SlidesExportCmd struct { diff --git a/internal/cmd/slides_insert_text.go b/internal/cmd/slides_insert_text.go new file mode 100644 index 0000000..125bb2a --- /dev/null +++ b/internal/cmd/slides_insert_text.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "google.golang.org/api/slides/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// SlidesInsertTextCmd inserts text into an existing text-capable page element. +// It is a thin wrapper around presentations.batchUpdate with an InsertTextRequest +// (optionally preceded by a DeleteText request when --replace is set). +type SlidesInsertTextCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + ObjectID string `arg:"" name:"objectId" help:"Page element object ID (shape or table) to insert text into"` + Text string `arg:"" name:"text" help:"Text to insert (use '-' to read from stdin)"` + InsertionIndex int64 `name:"insertion-index" help:"Zero-based index where text is inserted within the element's existing text" default:"0"` + Replace bool `name:"replace" help:"Clear existing text in the element before inserting (emits DeleteText + InsertText in the same batch)"` +} + +// Run executes the insert-text command. +func (c *SlidesInsertTextCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + presentationID := strings.TrimSpace(c.PresentationID) + if presentationID == "" { + return usage("empty presentationId") + } + objectID := strings.TrimSpace(c.ObjectID) + if objectID == "" { + return usage("empty objectId") + } + + // Resolve text: '-' means read from stdin. + text := c.Text + if text == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("read text from stdin: %w", err) + } + text = string(data) + } + + // Build the batchUpdate request body. + var requests []*slides.Request + if c.Replace { + requests = append(requests, &slides.Request{ + DeleteText: &slides.DeleteTextRequest{ + ObjectId: objectID, + TextRange: &slides.Range{ + Type: "ALL", + }, + }, + }) + } + requests = append(requests, &slides.Request{ + InsertText: &slides.InsertTextRequest{ + ObjectId: objectID, + Text: text, + InsertionIndex: c.InsertionIndex, + }, + }) + + body := &slides.BatchUpdatePresentationRequest{Requests: requests} + + // Dry-run: print the request body we would send and exit without calling the API. + if flags != nil && flags.DryRun { + return writeSlidesBatchUpdateDryRun(ctx, body) + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + slidesSvc, err := newSlidesService(ctx, account) + if err != nil { + return err + } + + resp, err := slidesSvc.Presentations.BatchUpdate(presentationID, body).Context(ctx).Do() + if err != nil { + return fmt.Errorf("insert text: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, resp) + } + + revisionID := "" + if resp != nil && resp.WriteControl != nil { + revisionID = resp.WriteControl.RequiredRevisionId + } + replies := 0 + if resp != nil { + replies = len(resp.Replies) + } + u.Out().Printf("ok | revisionId=%s | replies=%d", revisionID, replies) + return nil +} + +// writeSlidesBatchUpdateDryRun serializes a BatchUpdatePresentationRequest to +// stdout as pretty JSON. Used by slides commands that honor --dry-run. +func writeSlidesBatchUpdateDryRun(ctx context.Context, body *slides.BatchUpdatePresentationRequest) error { + if body == nil { + return errors.New("nil batch update request") + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(body); err != nil { + return fmt.Errorf("encode dry-run request: %w", err) + } + _ = ctx // reserved for future use (e.g., UI-aware formatting) + return nil +} diff --git a/internal/cmd/slides_insert_text_test.go b/internal/cmd/slides_insert_text_test.go new file mode 100644 index 0000000..5890596 --- /dev/null +++ b/internal/cmd/slides_insert_text_test.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/slides/v1" + + "github.com/steipete/gogcli/internal/ui" +) + +// mockSlidesBatchUpdateServer spins up an httptest.Server that captures the +// batchUpdate request body and returns a canned BatchUpdatePresentationResponse. +// Tests can inspect captured requests via the returned pointer. +func mockSlidesBatchUpdateServer( + t *testing.T, + captured *[]*slides.Request, + response map[string]any, +) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, ":batchUpdate") && r.Method == http.MethodPost { + var req slides.BatchUpdatePresentationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err == nil { + *captured = req.Requests + } + _ = json.NewEncoder(w).Encode(response) + return + } + http.NotFound(w, r) + })) + return srv +} + +func newSlidesServiceFromServer(t *testing.T, srv *httptest.Server) *slides.Service { + t.Helper() + svc, err := slides.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("slides.NewService: %v", err) + } + return svc +} + +func TestSlidesInsertText(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + "writeControl": map[string]any{"requiredRevisionId": "rev-123"}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + newSlidesService = func(context.Context, string) (*slides.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &SlidesInsertTextCmd{ + PresentationID: "pres1", + ObjectID: "shape_1", + Text: "hello world", + InsertionIndex: 3, + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ok | revisionId=rev-123 | replies=1") { + t.Errorf("expected plain confirmation with revisionId and replies, got: %q", out) + } + if len(captured) != 1 { + t.Fatalf("expected 1 request in batch, got %d", len(captured)) + } + if captured[0].InsertText == nil { + t.Fatal("expected InsertText request") + } + if captured[0].InsertText.Text != "hello world" { + t.Errorf("expected text %q, got %q", "hello world", captured[0].InsertText.Text) + } + if captured[0].InsertText.ObjectId != "shape_1" { + t.Errorf("expected objectId shape_1, got %q", captured[0].InsertText.ObjectId) + } + if captured[0].InsertText.InsertionIndex != 3 { + t.Errorf("expected insertionIndex 3, got %d", captured[0].InsertText.InsertionIndex) + } +} + +func TestSlidesInsertText_ReplaceEmitsDeleteThenInsert(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}, map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + newSlidesService = func(context.Context, string) (*slides.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) + + _ = captureStdout(t, func() { + cmd := &SlidesInsertTextCmd{ + PresentationID: "pres1", + ObjectID: "shape_1", + Text: "replacement", + Replace: true, + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if len(captured) != 2 { + t.Fatalf("expected 2 requests (DeleteText + InsertText), got %d", len(captured)) + } + if captured[0].DeleteText == nil { + t.Error("expected first request to be DeleteText") + } else if captured[0].DeleteText.TextRange == nil || captured[0].DeleteText.TextRange.Type != "ALL" { + t.Errorf("expected DeleteText TextRange.Type=ALL, got %+v", captured[0].DeleteText.TextRange) + } + if captured[1].InsertText == nil { + t.Error("expected second request to be InsertText") + } else if captured[1].InsertText.Text != "replacement" { + t.Errorf("expected inserted text %q, got %q", "replacement", captured[1].InsertText.Text) + } +} + +func TestSlidesInsertText_StdinDash(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{map[string]any{}}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + newSlidesService = func(context.Context, string) (*slides.Service, error) { return svc, nil } + + // Pipe text into stdin. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + origStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = origStdin }) + + const piped = "from-stdin content\nline 2\n" + go func() { + _, _ = w.Write([]byte(piped)) + _ = w.Close() + }() + + 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) + + _ = captureStdout(t, func() { + cmd := &SlidesInsertTextCmd{ + PresentationID: "pres1", + ObjectID: "shape_1", + Text: "-", + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if len(captured) != 1 || captured[0].InsertText == nil { + t.Fatalf("expected single InsertText request, got %+v", captured) + } + if captured[0].InsertText.Text != piped { + t.Errorf("expected piped text %q, got %q", piped, captured[0].InsertText.Text) + } +} + +func TestSlidesInsertText_DryRunNoAPICall(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + newSlidesService = func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created during dry-run") + return nil, context.Canceled + } + + flags := &RootFlags{Account: "a@b.com", DryRun: true} + 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) + + out := captureStdout(t, func() { + cmd := &SlidesInsertTextCmd{ + PresentationID: "pres1", + ObjectID: "shape_1", + Text: "dry", + Replace: true, + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var body slides.BatchUpdatePresentationRequest + if err := json.Unmarshal([]byte(out), &body); err != nil { + t.Fatalf("dry-run output should be valid JSON BatchUpdatePresentationRequest: %v\nout=%s", err, out) + } + if len(body.Requests) != 2 { + t.Fatalf("expected 2 requests in dry-run body, got %d", len(body.Requests)) + } + if body.Requests[0].DeleteText == nil || body.Requests[1].InsertText == nil { + t.Errorf("expected DeleteText then InsertText in dry-run body, got %+v", body.Requests) + } +} + +func TestSlidesInsertText_EmptyObjectID(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + newSlidesService = func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created") + return nil, context.Canceled + } + + 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) + + cmd := &SlidesInsertTextCmd{ + PresentationID: "pres1", + ObjectID: "", + Text: "something", + } + err := cmd.Run(ctx, flags) + if err == nil || !strings.Contains(err.Error(), "empty objectId") { + t.Fatalf("expected empty objectId error, got: %v", err) + } +} diff --git a/internal/cmd/slides_replace_text.go b/internal/cmd/slides_replace_text.go new file mode 100644 index 0000000..d7898ea --- /dev/null +++ b/internal/cmd/slides_replace_text.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/slides/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// SlidesReplaceTextCmd performs a find-and-replace across a presentation. +// It is a thin wrapper around presentations.batchUpdate with a single +// ReplaceAllTextRequest. +type SlidesReplaceTextCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + Find string `arg:"" name:"find" help:"Substring to find"` + Replacement string `arg:"" name:"replacement" help:"Replacement text"` + MatchCase bool `name:"match-case" help:"Case-sensitive match (default: false)"` + Pages []string `name:"page" help:"Restrict replacement to specific slide object IDs (repeatable)"` +} + +// Run executes the replace-text command. +func (c *SlidesReplaceTextCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + presentationID := strings.TrimSpace(c.PresentationID) + if presentationID == "" { + return usage("empty presentationId") + } + if c.Find == "" { + return usage("empty find text") + } + + // Build the batchUpdate request body. + req := &slides.ReplaceAllTextRequest{ + ContainsText: &slides.SubstringMatchCriteria{ + Text: c.Find, + MatchCase: c.MatchCase, + }, + ReplaceText: c.Replacement, + } + if len(c.Pages) > 0 { + // Preserve order and trim whitespace on each page id. + pages := make([]string, 0, len(c.Pages)) + for _, p := range c.Pages { + if p = strings.TrimSpace(p); p != "" { + pages = append(pages, p) + } + } + req.PageObjectIds = pages + } + + body := &slides.BatchUpdatePresentationRequest{ + Requests: []*slides.Request{{ReplaceAllText: req}}, + } + + // Dry-run: print the request body we would send and exit without calling the API. + if flags != nil && flags.DryRun { + return writeSlidesBatchUpdateDryRun(ctx, body) + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + slidesSvc, err := newSlidesService(ctx, account) + if err != nil { + return err + } + + resp, err := slidesSvc.Presentations.BatchUpdate(presentationID, body).Context(ctx).Do() + if err != nil { + return fmt.Errorf("replace text: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, resp) + } + + var replaced int64 + if resp != nil { + for _, r := range resp.Replies { + if r != nil && r.ReplaceAllText != nil { + replaced += r.ReplaceAllText.OccurrencesChanged + } + } + } + revisionID := "" + if resp != nil && resp.WriteControl != nil { + revisionID = resp.WriteControl.RequiredRevisionId + } + u.Out().Printf("ok | revisionId=%s | replaced=%d", revisionID, replaced) + return nil +} diff --git a/internal/cmd/slides_replace_text_test.go b/internal/cmd/slides_replace_text_test.go new file mode 100644 index 0000000..28daff5 --- /dev/null +++ b/internal/cmd/slides_replace_text_test.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "os" + "strings" + "testing" + + "google.golang.org/api/slides/v1" + + "github.com/steipete/gogcli/internal/ui" +) + +func TestSlidesReplaceText(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{ + map[string]any{ + "replaceAllText": map[string]any{"occurrencesChanged": 4}, + }, + }, + "writeControl": map[string]any{"requiredRevisionId": "rev-abc"}, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + newSlidesService = func(context.Context, string) (*slides.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &SlidesReplaceTextCmd{ + PresentationID: "pres1", + Find: "oldName", + Replacement: "newName", + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if !strings.Contains(out, "ok | revisionId=rev-abc | replaced=4") { + t.Errorf("expected confirmation with revisionId + replaced count, got: %q", out) + } + if len(captured) != 1 { + t.Fatalf("expected 1 request in batch, got %d", len(captured)) + } + if captured[0].ReplaceAllText == nil { + t.Fatal("expected ReplaceAllText request") + } + if captured[0].ReplaceAllText.ContainsText == nil { + t.Fatal("expected ContainsText set on request") + } + if captured[0].ReplaceAllText.ContainsText.Text != "oldName" { + t.Errorf("expected find text %q, got %q", "oldName", captured[0].ReplaceAllText.ContainsText.Text) + } + if captured[0].ReplaceAllText.ContainsText.MatchCase { + t.Error("expected MatchCase=false by default") + } + if captured[0].ReplaceAllText.ReplaceText != "newName" { + t.Errorf("expected replacement %q, got %q", "newName", captured[0].ReplaceAllText.ReplaceText) + } + if len(captured[0].ReplaceAllText.PageObjectIds) != 0 { + t.Errorf("expected no PageObjectIds, got %+v", captured[0].ReplaceAllText.PageObjectIds) + } +} + +func TestSlidesReplaceText_MatchCaseAndPages(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + "presentationId": "pres1", + "replies": []any{ + map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 1}}, + }, + }) + defer srv.Close() + + svc := newSlidesServiceFromServer(t, srv) + newSlidesService = func(context.Context, string) (*slides.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) + + _ = captureStdout(t, func() { + cmd := &SlidesReplaceTextCmd{ + PresentationID: "pres1", + Find: "FooBar", + Replacement: "BazQux", + MatchCase: true, + Pages: []string{"slide_1", "slide_2"}, + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if len(captured) != 1 || captured[0].ReplaceAllText == nil { + t.Fatalf("expected 1 ReplaceAllText request, got %+v", captured) + } + if !captured[0].ReplaceAllText.ContainsText.MatchCase { + t.Error("expected MatchCase=true") + } + got := captured[0].ReplaceAllText.PageObjectIds + want := []string{"slide_1", "slide_2"} + if len(got) != len(want) { + t.Fatalf("expected %d pageObjectIds, got %d (%+v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("pageObjectIds[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestSlidesReplaceText_DryRunNoAPICall(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + newSlidesService = func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created during dry-run") + return nil, context.Canceled + } + + flags := &RootFlags{Account: "a@b.com", DryRun: true} + 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) + + out := captureStdout(t, func() { + cmd := &SlidesReplaceTextCmd{ + PresentationID: "pres1", + Find: "needle", + Replacement: "thread", + MatchCase: true, + Pages: []string{"p1"}, + } + if err := cmd.Run(ctx, flags); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var body slides.BatchUpdatePresentationRequest + if err := json.Unmarshal([]byte(out), &body); err != nil { + t.Fatalf("dry-run output should be valid JSON: %v\nout=%s", err, out) + } + if len(body.Requests) != 1 || body.Requests[0].ReplaceAllText == nil { + t.Fatalf("expected single ReplaceAllText request in dry-run, got %+v", body.Requests) + } + rr := body.Requests[0].ReplaceAllText + if rr.ReplaceText != "thread" || rr.ContainsText.Text != "needle" || !rr.ContainsText.MatchCase { + t.Errorf("unexpected dry-run request body: %+v", rr) + } + if len(rr.PageObjectIds) != 1 || rr.PageObjectIds[0] != "p1" { + t.Errorf("expected pageObjectIds=[p1], got %+v", rr.PageObjectIds) + } +} + +func TestSlidesReplaceText_EmptyFind(t *testing.T) { + origSlides := newSlidesService + t.Cleanup(func() { newSlidesService = origSlides }) + + newSlidesService = func(context.Context, string) (*slides.Service, error) { + t.Fatal("slides service should not be created") + return nil, context.Canceled + } + + 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) + + cmd := &SlidesReplaceTextCmd{ + PresentationID: "pres1", + Find: "", + Replacement: "anything", + } + err := cmd.Run(ctx, flags) + if err == nil || !strings.Contains(err.Error(), "empty find") { + t.Fatalf("expected empty find error, got: %v", err) + } +}