feat(slides): add create-from-template command with text replacement

Adds `gog slides create-from-template` command to create presentations
from templates with automatic placeholder text replacement.

Features:
- {{key}} placeholder format (auto-wrapped)
- Multiple replacement sources: --replace flags and --replacements JSON file
- Type conversion for JSON (numbers, booleans → strings)
- --exact flag for arbitrary string replacement
- Replacement statistics in output
- JSON/text output modes

Implementation:
- Copy template via Drive API
- Batch text replacement via Slides API ReplaceAllText
- Comprehensive error handling and validation
- 7 test cases covering all features and edge cases

Usage: gog slides create-from-template <templateId> <title> \
    --replace "key=value" \
    --replacements data.json
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
penguinco 2026-02-16 01:11:00 +09:00 committed by Peter Steinberger
parent ffd8cd482b
commit 922c1fbb8b
6 changed files with 1137 additions and 1 deletions

View File

@ -16,6 +16,7 @@
- Gmail: add `gmail labels rename` to rename user labels by ID or exact name, with system-label guards and wrong-case ID safety. (#391) — thanks @adam-zethraeus.
- Gmail: add `gmail messages modify` for single-message label changes, complementing thread- and batch-level modify flows. (#281) — thanks @zerone0x.
- Calendar: add `calendar subscribe` (aliases `sub`, `add-calendar`) to add a shared calendar to the current accounts calendar list. (#327) — thanks @cdthompson.
- Slides: add `create-from-template` with `--replace` / `--replacements`, dry-run support, and template placeholder replacement stats. (#273) — thanks @penguinco.
- Sheets: add `sheets update-note` / `set-note` to write or clear cell notes across a range. (#430) — thanks @andybergon.
- Sheets: add `sheets create --parent` to place new spreadsheets in a Drive folder. (#424) — thanks @ManManavadaria.
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.

View File

@ -18,7 +18,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
- **Sheets** - read/write/update spreadsheets, insert rows/cols, format cells, read notes, create new sheets (and export via Drive)
- **Forms** - create/get forms and inspect responses
- **Apps Script** - create/get projects, inspect content, and run functions
- **Docs/Slides** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text, and **sedmat** sed-style document editing with Markdown formatting, images, and tables)
- **Docs/Slides** - export to PDF/DOCX/PPTX via Drive (plus create/copy, docs-to-text, template-based creation with text replacement, and **sedmat** sed-style document editing with Markdown formatting, images, and tables)
- **People** - access profile information
- **Keep (Workspace only)** - list/get/search notes and download attachments (service account + domain-wide delegation)
- **Groups** - list groups you belong to, view group members (Google Workspace)
@ -916,6 +916,7 @@ gog docs find-replace <docId> "old" "new" --tab-id t.notes
gog slides info <presentationId>
gog slides create "My Deck"
gog slides create-from-markdown "My Deck" --content-file ./slides.md
gog slides create-from-template <templateId> "My Deck" --replace "name=John" --replace "date=2026-02-15"
gog slides copy <presentationId> "My Deck Copy"
gog slides export <presentationId> --format pdf --out ./deck.pdf
gog slides list-slides <presentationId>
@ -1263,6 +1264,26 @@ gog docs sed <docId> 's/|1|[col:$+]//' # append column at end
# Export (via Drive)
gog slides export <presentationId> --format pptx --out ./deck.pptx
gog slides export <presentationId> --format pdf --out ./deck.pdf
# Create from template with text replacements
gog slides create-from-template <templateId> "Q1 Report" \
--replace "quarter=Q1 2026" \
--replace "revenue=$1.2M" \
--replace "growth=15%"
# Use JSON file for many replacements
cat > replacements.json <<EOF
{
"name": "John Doe",
"title": "Sales Manager",
"date": "2026-02-15",
"sales": "125",
"target": "100"
}
EOF
gog slides create-from-template <templateId> "Monthly Report" \
--replacements replacements.json
```
## Output Formats

View File

@ -0,0 +1,226 @@
# Google Slides Template Text Replacement
## Overview
The `gog slides create-from-template` command allows you to create a new Google Slides presentation from a template and automatically replace placeholder text throughout the presentation.
## Basic Usage
```bash
gog slides create-from-template <templateId> <title> \
--replace "key=value" \
--replace "another=value"
```
## Features
### 1. Placeholder Format
By default, the command looks for placeholders in the format `{{key}}` in your template:
- In your template, add text like: `{{name}}`, `{{date}}`, `{{company}}`
- The command automatically wraps keys with `{{}}` if not already present
- Use `--exact` flag to match exact strings without `{{}}` wrapping
### 2. Multiple Replacement Sources
#### Command-line flags (for a few replacements):
```bash
gog slides create-from-template 1abc123 "Q1 Report" \
--replace "quarter=Q1 2026" \
--replace "revenue=$1.2M" \
--replace "growth=15%"
```
#### JSON file (for many replacements):
Create a JSON file with your replacements:
```json
{
"name": "John Doe",
"title": "Sales Manager",
"date": "2026-02-15",
"sales": 125,
"target": 100,
"achieved": true
}
```
Then use it:
```bash
gog slides create-from-template 1abc123 "Monthly Report" \
--replacements replacements.json
```
#### Combining both (flags override file):
```bash
gog slides create-from-template 1abc123 "Report" \
--replacements base-data.json \
--replace "date=2026-02-15" # This overrides "date" from JSON
```
### 3. Type Conversion
When using JSON files, non-string values are automatically converted:
- Numbers: `125``"125"`
- Booleans: `true``"true"`
- Null: `null``""`
- Complex types: JSON-encoded
### 4. Exact String Matching
Use `--exact` to replace arbitrary text without `{{}}` wrapping:
```bash
gog slides create-from-template 1abc123 "Report" \
--replace "OLD_TEXT=NEW_TEXT" \
--exact
```
This is useful for templates not using the `{{key}}` convention.
## Examples
### Example 1: Simple Report Generation
Template contains:
- `{{employee_name}}`
- `{{report_date}}`
- `{{sales_total}}`
```bash
gog slides create-from-template 1abc123def456 "January Sales Report" \
--replace "employee_name=Jane Smith" \
--replace "report_date=2026-01-31" \
--replace "sales_total=$45,000"
```
### Example 2: Batch Report Generation
Create `report-data.json`:
```json
{
"month": "February",
"year": "2026",
"sales": "$52,000",
"target": "$50,000",
"performance": "104%"
}
```
Generate report:
```bash
gog slides create-from-template 1abc123def456 "February Sales Report" \
--replacements report-data.json \
--parent 1xyz789abc123 # Optional: place in specific folder
```
### Example 3: Scripted Bulk Generation
```bash
#!/bin/bash
TEMPLATE_ID="1abc123def456"
# Read CSV and generate presentations
while IFS=, read -r name title date sales; do
gog slides create-from-template "$TEMPLATE_ID" "Report - $name" \
--replace "name=$name" \
--replace "title=$title" \
--replace "date=$date" \
--replace "sales=$sales" \
--json > "output-$name.json"
done < employees.csv
```
## Output
### Text Output
```
Created presentation from template
id 1new456presentation
name Q1 Report
link https://docs.google.com/presentation/d/1new456presentation/edit
Replacements:
quarter 3 occurrences
revenue 2 occurrences
growth 1 occurrences
```
### JSON Output
```bash
gog slides create-from-template 1abc123 "Report" \
--replace "name=John" \
--json
```
```json
{
"presentationId": "1new456presentation",
"name": "Report",
"link": "https://docs.google.com/presentation/d/1new456presentation/edit",
"replacements": {
"name": 3,
"date": 2,
"total": 1
}
}
```
## Tips
1. **Test your template first**: Create a test presentation to verify all placeholders are correctly placed
2. **Use consistent naming**: Stick to `{{key}}` format for clarity
3. **Check replacement counts**: The output shows how many times each placeholder was found
4. **Use JSON for complex data**: Easier to manage many fields or computed values
5. **Save folder IDs**: Use `--parent` to organize generated presentations
## Troubleshooting
### Placeholder not found (0 occurrences)
- Verify the placeholder exists in the template
- Check for typos in placeholder names (case-sensitive by default)
- Ensure `{{}}` wrapping is correct (or use `--exact`)
### Permission denied
- Ensure you have edit access to the template
- Check that the Google Slides API is enabled for your OAuth client
### Template copy failed
- Verify the template ID is correct
- Check that the template is accessible with your account
- Ensure it's a Google Slides presentation (not a document or sheet)
## Advanced Usage
### Preserve some placeholders
To replace only some placeholders and keep others for later editing:
```bash
# Only replace quarter and keep other {{}} placeholders
gog slides create-from-template 1abc123 "Draft Report" \
--replace "quarter=Q1 2026"
```
### Case-sensitive vs case-insensitive
By default, replacements are case-sensitive. All placeholders and keys must match exactly.
### Whitespace in values
Values are not trimmed, allowing intentional whitespace:
```bash
--replace "description= Indented text"
```

View File

@ -24,6 +24,7 @@ type SlidesCmd struct {
Info SlidesInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Slides presentation metadata"`
Create SlidesCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Slides presentation"`
CreateFromMarkdown SlidesCreateFromMarkdownCmd `cmd:"" name:"create-from-markdown" help:"Create a Google Slides presentation from markdown"`
CreateFromTemplate SlidesCreateFromTemplateCmd `cmd:"" name:"create-from-template" help:"Create a presentation from template with text replacements"`
Copy SlidesCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Slides presentation"`
AddSlide SlidesAddSlideCmd `cmd:"" name:"add-slide" help:"Add a slide with a full-bleed image and optional speaker notes"`
ListSlides SlidesListSlidesCmd `cmd:"" name:"list-slides" help:"List all slides with their object IDs"`

View File

@ -0,0 +1,250 @@
package cmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"google.golang.org/api/drive/v3"
"google.golang.org/api/slides/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type SlidesCreateFromTemplateCmd struct {
TemplateID string `arg:"" name:"templateId" help:"Template presentation ID"`
Title string `arg:"" name:"title" help:"New presentation title"`
Replace []string `name:"replace" help:"Text replacement in format 'key=value' (repeatable)"`
Replacements string `name:"replacements" help:"JSON file containing replacements" type:"existingfile"`
Parent string `name:"parent" help:"Destination folder ID"`
Exact bool `name:"exact" help:"Use exact string matching instead of {{key}} placeholders"`
}
func (c *SlidesCreateFromTemplateCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
templateID := normalizeGoogleID(strings.TrimSpace(c.TemplateID))
if templateID == "" {
return usage("empty templateId")
}
title := strings.TrimSpace(c.Title)
if title == "" {
return usage("empty title")
}
// Parse replacements from both sources
replacements, err := c.parseReplacements()
if err != nil {
return err
}
if len(replacements) == 0 {
return usage("no replacements specified (use --replace or --replacements)")
}
parent := normalizeGoogleID(strings.TrimSpace(c.Parent))
if dryRunErr := dryRunExit(ctx, flags, "slides.create-from-template", map[string]any{
"template_id": templateID,
"title": title,
"parent": parent,
"exact": c.Exact,
"replacements": replacements,
}); dryRunErr != nil {
return dryRunErr
}
// Create Drive service to copy the template
driveSvc, err := newDriveService(ctx, account)
if err != nil {
return err
}
// Copy template
f := &drive.File{
Name: title,
}
if parent != "" {
f.Parents = []string{parent}
}
created, err := driveSvc.Files.Copy(templateID, f).
SupportsAllDrives(true).
Fields("id, name, mimeType, webViewLink").
Context(ctx).
Do()
if err != nil {
return fmt.Errorf("failed to copy template: %w", err)
}
if created == nil {
return errors.New("template copy failed")
}
// Verify it's a presentation
if created.MimeType != "application/vnd.google-apps.presentation" {
return fmt.Errorf("template is not a Google Slides presentation (got %s)", created.MimeType)
}
presentationID := created.Id
// Create Slides service for text replacement
slidesSvc, err := newSlidesService(ctx, account)
if err != nil {
return fmt.Errorf("failed to create slides service: %w", err)
}
// Build batch update requests for text replacement
requests := make([]*slides.Request, 0, len(replacements))
for key, value := range replacements {
searchText := key
if !c.Exact {
// Wrap with {{}} if not already present
if !strings.HasPrefix(key, "{{") || !strings.HasSuffix(key, "}}") {
searchText = fmt.Sprintf("{{%s}}", key)
}
}
requests = append(requests, &slides.Request{
ReplaceAllText: &slides.ReplaceAllTextRequest{
ContainsText: &slides.SubstringMatchCriteria{
Text: searchText,
MatchCase: true,
},
ReplaceText: value,
},
})
}
// Execute batch update
result, err := slidesSvc.Presentations.BatchUpdate(presentationID, &slides.BatchUpdatePresentationRequest{
Requests: requests,
}).Context(ctx).Do()
if err != nil {
u.Err().Printf("Warning: presentation created but text replacement failed: %v", err)
u.Err().Printf("Presentation ID: %s", presentationID)
u.Err().Printf("You may need to manually edit or delete this presentation")
return fmt.Errorf("text replacement failed: %w", err)
}
// Collect replacement statistics
replacementStats := make(map[string]int64)
for i, reply := range result.Replies {
if reply.ReplaceAllText != nil {
// Get the original key from requests
if i < len(requests) && requests[i].ReplaceAllText != nil {
searchText := requests[i].ReplaceAllText.ContainsText.Text
// Remove {{}} for display if present
displayKey := strings.TrimSuffix(strings.TrimPrefix(searchText, "{{"), "}}")
replacementStats[displayKey] = reply.ReplaceAllText.OccurrencesChanged
}
}
}
// Output results
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"presentationId": presentationID,
"name": created.Name,
"link": created.WebViewLink,
"replacements": replacementStats,
})
}
u.Out().Printf("Created presentation from template")
u.Out().Printf("id\t%s", presentationID)
u.Out().Printf("name\t%s", created.Name)
if created.WebViewLink != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
if len(replacementStats) > 0 {
u.Out().Println("")
u.Out().Println("Replacements:")
for key, count := range replacementStats {
if count > 0 {
u.Out().Printf(" %s\t%d occurrences", key, count)
} else {
u.Out().Printf(" %s\tnot found", key)
}
}
}
return nil
}
// parseReplacements combines replacements from --replace flags and --replacements file
func (c *SlidesCreateFromTemplateCmd) parseReplacements() (map[string]string, error) {
result := make(map[string]string)
// Load from JSON file first
if c.Replacements != "" {
data, err := os.ReadFile(c.Replacements)
if err != nil {
return nil, fmt.Errorf("failed to read replacements file: %w", err)
}
var fileReplacements map[string]interface{}
if err := json.Unmarshal(data, &fileReplacements); err != nil {
return nil, fmt.Errorf("invalid JSON in replacements file: %w", err)
}
// Convert all values to strings
for k, v := range fileReplacements {
k = strings.TrimSpace(k)
if k == "" {
continue
}
switch val := v.(type) {
case string:
result[k] = val
case float64:
result[k] = fmt.Sprintf("%g", val)
case bool:
result[k] = fmt.Sprintf("%t", val)
case nil:
result[k] = ""
default:
// Try to marshal back to JSON for complex types
jsonVal, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("cannot convert value for key %q to string: %w", k, err)
}
result[k] = string(jsonVal)
}
}
}
// Process --replace flags (these override file values)
for _, replacement := range c.Replace {
parts := strings.SplitN(replacement, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid replacement format %q (expected key=value)", replacement)
}
key := strings.TrimSpace(parts[0])
value := parts[1] // Don't trim value - it might be intentionally whitespace
if key == "" {
return nil, fmt.Errorf("empty key in replacement %q", replacement)
}
// Warn if using non-placeholder key without --exact flag
if !c.Exact && !strings.HasPrefix(key, "{{") && !strings.HasSuffix(key, "}}") {
// This is OK, we'll add {{}} automatically
}
result[key] = value
}
return result, nil
}

View File

@ -0,0 +1,637 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"google.golang.org/api/slides/v1"
"github.com/steipete/gogcli/internal/ui"
)
func TestSlidesCreateFromTemplate_Basic(t *testing.T) {
var capturedDriveRequest *http.Request
var capturedSlidesRequests []*slides.Request
driveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedDriveRequest = r
// Handle copy request - the path includes /v3/files/{id}/copy
if r.Method == "POST" && strings.Contains(r.URL.Path, "/files/template123/copy") {
response := &drive.File{
Id: "copied123",
Name: "New Presentation",
MimeType: "application/vnd.google-apps.presentation",
WebViewLink: "https://docs.google.com/presentation/d/copied123/edit",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer driveServer.Close()
slidesServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/presentations/copied123:batchUpdate" {
var req slides.BatchUpdatePresentationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
capturedSlidesRequests = req.Requests
// Build response with replacement statistics
replies := make([]*slides.Response, len(req.Requests))
for i := range req.Requests {
replies[i] = &slides.Response{
ReplaceAllText: &slides.ReplaceAllTextResponse{
OccurrencesChanged: 2,
},
}
}
response := &slides.BatchUpdatePresentationResponse{
PresentationId: "copied123",
Replies: replies,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer slidesServer.Close()
// Create Drive service
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(driveServer.URL))
if err != nil {
t.Fatal(err)
}
// Create Slides service
slidesSvc, err := slides.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(slidesServer.URL))
if err != nil {
t.Fatal(err)
}
oldNewDrive := newDriveService
oldNewSlides := newSlidesService
defer func() {
newDriveService = oldNewDrive
newSlidesService = oldNewSlides
}()
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
newSlidesService = func(context.Context, string) (*slides.Service, error) { return slidesSvc, nil }
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template123",
Title: "New Presentation",
Replace: []string{"name=John Doe", "company=ACME Corp"},
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err = cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// Verify Drive API call
if capturedDriveRequest == nil {
t.Fatal("Drive API was not called")
}
// Verify Slides API calls
if len(capturedSlidesRequests) != 2 {
t.Fatalf("Expected 2 replacement requests, got %d", len(capturedSlidesRequests))
}
// Check first replacement
if capturedSlidesRequests[0].ReplaceAllText == nil {
t.Fatal("First request is not ReplaceAllText")
}
if capturedSlidesRequests[0].ReplaceAllText.ContainsText.Text != "{{name}}" {
t.Errorf("Expected {{name}}, got %s", capturedSlidesRequests[0].ReplaceAllText.ContainsText.Text)
}
if capturedSlidesRequests[0].ReplaceAllText.ReplaceText != "John Doe" {
t.Errorf("Expected 'John Doe', got %s", capturedSlidesRequests[0].ReplaceAllText.ReplaceText)
}
// Check second replacement
if capturedSlidesRequests[1].ReplaceAllText == nil {
t.Fatal("Second request is not ReplaceAllText")
}
if capturedSlidesRequests[1].ReplaceAllText.ContainsText.Text != "{{company}}" {
t.Errorf("Expected {{company}}, got %s", capturedSlidesRequests[1].ReplaceAllText.ContainsText.Text)
}
if capturedSlidesRequests[1].ReplaceAllText.ReplaceText != "ACME Corp" {
t.Errorf("Expected 'ACME Corp', got %s", capturedSlidesRequests[1].ReplaceAllText.ReplaceText)
}
}
func TestSlidesCreateFromTemplate_JSONFile(t *testing.T) {
tmpDir := t.TempDir()
jsonFile := filepath.Join(tmpDir, "replacements.json")
replacements := map[string]interface{}{
"name": "Jane Smith",
"age": 30,
"active": true,
"company": "TechCorp",
}
data, err := json.Marshal(replacements)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(jsonFile, data, 0o644); err != nil {
t.Fatal(err)
}
var capturedSlidesRequests []*slides.Request
driveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && strings.Contains(r.URL.Path, "/files/template456/copy") {
response := &drive.File{
Id: "copied456",
Name: "Test Presentation",
MimeType: "application/vnd.google-apps.presentation",
WebViewLink: "https://docs.google.com/presentation/d/copied456/edit",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer driveServer.Close()
slidesServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/presentations/copied456:batchUpdate" {
var req slides.BatchUpdatePresentationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
capturedSlidesRequests = req.Requests
replies := make([]*slides.Response, len(req.Requests))
for i := range req.Requests {
replies[i] = &slides.Response{
ReplaceAllText: &slides.ReplaceAllTextResponse{
OccurrencesChanged: 1,
},
}
}
response := &slides.BatchUpdatePresentationResponse{
PresentationId: "copied456",
Replies: replies,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer slidesServer.Close()
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(driveServer.URL))
if err != nil {
t.Fatal(err)
}
slidesSvc, err := slides.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(slidesServer.URL))
if err != nil {
t.Fatal(err)
}
oldNewDrive := newDriveService
oldNewSlides := newSlidesService
defer func() {
newDriveService = oldNewDrive
newSlidesService = oldNewSlides
}()
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
newSlidesService = func(context.Context, string) (*slides.Service, error) { return slidesSvc, nil }
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template456",
Title: "Test Presentation",
Replacements: jsonFile,
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err = cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// Should have 4 replacements
if len(capturedSlidesRequests) != 4 {
t.Fatalf("Expected 4 replacement requests, got %d", len(capturedSlidesRequests))
}
// Verify type conversions
foundAge := false
foundActive := false
for _, req := range capturedSlidesRequests {
if req.ReplaceAllText != nil {
text := req.ReplaceAllText.ContainsText.Text
if text == "{{age}}" {
foundAge = true
if req.ReplaceAllText.ReplaceText != "30" {
t.Errorf("Expected age '30', got %s", req.ReplaceAllText.ReplaceText)
}
}
if text == "{{active}}" {
foundActive = true
if req.ReplaceAllText.ReplaceText != "true" {
t.Errorf("Expected active 'true', got %s", req.ReplaceAllText.ReplaceText)
}
}
}
}
if !foundAge {
t.Error("Did not find age replacement")
}
if !foundActive {
t.Error("Did not find active replacement")
}
}
func TestSlidesCreateFromTemplate_ExactMode(t *testing.T) {
var capturedSlidesRequests []*slides.Request
driveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && strings.Contains(r.URL.Path, "/files/template789/copy") {
response := &drive.File{
Id: "copied789",
Name: "Exact Mode Test",
MimeType: "application/vnd.google-apps.presentation",
WebViewLink: "https://docs.google.com/presentation/d/copied789/edit",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer driveServer.Close()
slidesServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/presentations/copied789:batchUpdate" {
var req slides.BatchUpdatePresentationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
capturedSlidesRequests = req.Requests
replies := make([]*slides.Response, len(req.Requests))
for i := range req.Requests {
replies[i] = &slides.Response{
ReplaceAllText: &slides.ReplaceAllTextResponse{
OccurrencesChanged: 1,
},
}
}
response := &slides.BatchUpdatePresentationResponse{
PresentationId: "copied789",
Replies: replies,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer slidesServer.Close()
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(driveServer.URL))
if err != nil {
t.Fatal(err)
}
slidesSvc, err := slides.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(slidesServer.URL))
if err != nil {
t.Fatal(err)
}
oldNewDrive := newDriveService
oldNewSlides := newSlidesService
defer func() {
newDriveService = oldNewDrive
newSlidesService = oldNewSlides
}()
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
newSlidesService = func(context.Context, string) (*slides.Service, error) { return slidesSvc, nil }
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template789",
Title: "Exact Mode Test",
Replace: []string{"OLD_TEXT=NEW_TEXT"},
Exact: true,
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err = cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err != nil {
t.Fatalf("Run failed: %v", err)
}
if len(capturedSlidesRequests) != 1 {
t.Fatalf("Expected 1 replacement request, got %d", len(capturedSlidesRequests))
}
// In exact mode, should search for "OLD_TEXT" not "{{OLD_TEXT}}"
if capturedSlidesRequests[0].ReplaceAllText.ContainsText.Text != "OLD_TEXT" {
t.Errorf("Expected 'OLD_TEXT', got %s", capturedSlidesRequests[0].ReplaceAllText.ContainsText.Text)
}
}
func TestSlidesCreateFromTemplate_EmptyReplacements(t *testing.T) {
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template123",
Title: "Test",
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err == nil {
t.Fatal("Expected error for empty replacements, got nil")
}
if ExitCode(err) != 2 {
t.Errorf("Expected usage error (exit code 2), got: %v", err)
}
}
func TestSlidesCreateFromTemplate_InvalidReplaceFormat(t *testing.T) {
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template123",
Title: "Test",
Replace: []string{"invalid_no_equals_sign"},
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err == nil {
t.Fatal("Expected error for invalid replace format, got nil")
}
}
func TestSlidesCreateFromTemplate_InvalidJSON(t *testing.T) {
tmpDir := t.TempDir()
jsonFile := filepath.Join(tmpDir, "invalid.json")
if err := os.WriteFile(jsonFile, []byte("{invalid json}"), 0o644); err != nil {
t.Fatal(err)
}
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template123",
Title: "Test",
Replacements: jsonFile,
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err := cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err == nil {
t.Fatal("Expected error for invalid JSON, got nil")
}
}
func TestSlidesCreateFromTemplate_CombineFileAndFlags(t *testing.T) {
tmpDir := t.TempDir()
jsonFile := filepath.Join(tmpDir, "replacements.json")
fileReplacements := map[string]string{
"name": "From File",
"company": "File Corp",
}
data, err := json.Marshal(fileReplacements)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(jsonFile, data, 0o644); err != nil {
t.Fatal(err)
}
var capturedSlidesRequests []*slides.Request
driveServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
response := &drive.File{
Id: "copied999",
Name: "Combined Test",
MimeType: "application/vnd.google-apps.presentation",
WebViewLink: "https://docs.google.com/presentation/d/copied999/edit",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer driveServer.Close()
slidesServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
var req slides.BatchUpdatePresentationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
capturedSlidesRequests = req.Requests
replies := make([]*slides.Response, len(req.Requests))
for i := range req.Requests {
replies[i] = &slides.Response{
ReplaceAllText: &slides.ReplaceAllTextResponse{
OccurrencesChanged: 1,
},
}
}
response := &slides.BatchUpdatePresentationResponse{
PresentationId: "copied999",
Replies: replies,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
defer slidesServer.Close()
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(driveServer.URL))
if err != nil {
t.Fatal(err)
}
slidesSvc, err := slides.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithEndpoint(slidesServer.URL))
if err != nil {
t.Fatal(err)
}
oldNewDrive := newDriveService
oldNewSlides := newSlidesService
defer func() {
newDriveService = oldNewDrive
newSlidesService = oldNewSlides
}()
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
newSlidesService = func(context.Context, string) (*slides.Service, error) { return slidesSvc, nil }
// Flag overrides file
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template999",
Title: "Combined Test",
Replacements: jsonFile,
Replace: []string{"name=From Flag"},
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err = cmd.Run(ctx, &RootFlags{Account: "test@example.com"})
if err != nil {
t.Fatalf("Run failed: %v", err)
}
// Should have 2 replacements (name and company)
if len(capturedSlidesRequests) != 2 {
t.Fatalf("Expected 2 replacement requests, got %d", len(capturedSlidesRequests))
}
// Verify that flag value overrides file value
foundNameOverride := false
for _, req := range capturedSlidesRequests {
if req.ReplaceAllText != nil && req.ReplaceAllText.ContainsText.Text == "{{name}}" {
foundNameOverride = true
if req.ReplaceAllText.ReplaceText != "From Flag" {
t.Errorf("Expected 'From Flag', got %s", req.ReplaceAllText.ReplaceText)
}
}
}
if !foundNameOverride {
t.Error("Flag should override file value for 'name'")
}
}
func TestSlidesCreateFromTemplate_DryRunSkipsAPICalls(t *testing.T) {
origNewDrive := newDriveService
origNewSlides := newSlidesService
t.Cleanup(func() {
newDriveService = origNewDrive
newSlidesService = origNewSlides
})
driveCalls := 0
slidesCalls := 0
newDriveService = func(context.Context, string) (*drive.Service, error) {
driveCalls++
t.Fatal("drive service should not be created during dry-run")
return nil, nil
}
newSlidesService = func(context.Context, string) (*slides.Service, error) {
slidesCalls++
t.Fatal("slides service should not be created during dry-run")
return nil, nil
}
cmd := &SlidesCreateFromTemplateCmd{
TemplateID: "template123",
Title: "Dry Run Deck",
Replace: []string{"name=John Doe"},
Parent: "https://drive.google.com/drive/folders/parent123",
}
u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
err := cmd.Run(ctx, &RootFlags{Account: "test@example.com", DryRun: true, NoInput: true})
if ExitCode(err) != 0 {
t.Fatalf("expected dry-run exit 0, got %v", err)
}
if driveCalls != 0 || slidesCalls != 0 {
t.Fatalf("expected no API calls, got drive=%d slides=%d", driveCalls, slidesCalls)
}
}