feat(slides): add insert-text and replace-text commands

Thin wrappers around presentations.batchUpdate for surgical text edits on
an existing deck.

- insert-text inserts text into a page element by objectId, with
  --insertion-index, --replace (emits DeleteText+InsertText), and
  stdin support via '-'.
- replace-text runs ReplaceAllText across the deck, with --match-case
  and repeatable --page for slide-scoped replacements.

Both honor the global --dry-run (prints the batchUpdate request body as
JSON without calling the API) and --json (emits the full
BatchUpdatePresentationResponse). Plain output gives a one-line
confirmation with revisionId + replies/replaced count. Style mirrors
existing update-notes and replace-slide commands.
This commit is contained in:
Chris Sanchez 2026-04-22 15:26:31 -07:00 committed by Peter Steinberger
parent 0d93cb2a05
commit d047a0325f
No known key found for this signature in database
6 changed files with 738 additions and 0 deletions

View File

@ -1055,6 +1055,11 @@ gog slides list-slides <presentationId>
gog slides add-slide <presentationId> ./slide.png --notes "Speaker notes"
gog slides update-notes <presentationId> <slideId> --notes "Updated notes"
gog slides replace-slide <presentationId> <slideId> ./new-slide.png --notes "New notes"
gog slides insert-text <presentationId> <objectId> "Text to insert"
gog slides insert-text <presentationId> <objectId> - < long-content.md
gog slides insert-text <presentationId> <objectId> "New body" --replace
gog slides replace-text <presentationId> "{{name}}" "Acme Corp"
gog slides replace-text <presentationId> "TODO" "DONE" --match-case --page <slideId1> --page <slideId2>
# Sheets
gog sheets copy <spreadsheetId> "My Sheet Copy"
@ -1474,6 +1479,32 @@ gog slides thumbnail <presentationId> <slideId> --output ./slide.png
# Control thumbnail size and format
gog slides thumbnail <presentationId> <slideId> --size medium --format jpeg --output ./slide.jpg
# Insert text into an existing text-capable element (shape or table cell)
gog slides insert-text <presentationId> <objectId> "Hello, world"
# Insert at a specific position in the element's existing text
gog slides insert-text <presentationId> <objectId> " (inserted)" --insertion-index 12
# Replace the element's existing text wholesale (DeleteText + InsertText in one batch)
gog slides insert-text <presentationId> <objectId> "Brand-new body copy" --replace
# Read long content from stdin
cat long-content.md | gog slides insert-text <presentationId> <objectId> -
# Preview the batchUpdate request body without executing it
gog slides insert-text <presentationId> <objectId> "demo" --replace --dry-run
# Find-and-replace across the whole deck
gog slides replace-text <presentationId> "{{customer_name}}" "Acme Corp"
# Case-sensitive match, restricted to specific slides
gog slides replace-text <presentationId> "TODO" "DONE" \
--match-case \
--page <slideId1> --page <slideId2>
# Preview the replace request without executing it
gog slides replace-text <presentationId> "old" "new" --dry-run
```
## Output Formats

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}