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:
parent
0d93cb2a05
commit
d047a0325f
31
README.md
31
README.md
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
123
internal/cmd/slides_insert_text.go
Normal file
123
internal/cmd/slides_insert_text.go
Normal 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
|
||||
}
|
||||
279
internal/cmd/slides_insert_text_test.go
Normal file
279
internal/cmd/slides_insert_text_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
99
internal/cmd/slides_replace_text.go
Normal file
99
internal/cmd/slides_replace_text.go
Normal 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
|
||||
}
|
||||
204
internal/cmd/slides_replace_text_test.go
Normal file
204
internal/cmd/slides_replace_text_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user