From 922c1fbb8b06e653875af5761b0ec99b687626e6 Mon Sep 17 00:00:00 2001 From: penguinco Date: Mon, 16 Feb 2026 01:11:00 +0900 Subject: [PATCH] feat(slides): add create-from-template command with text replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 \ --replace "key=value" \ --replacements data.json Co-authored-by: Cursor <cursoragent@cursor.com> --- CHANGELOG.md | 1 + README.md | 23 +- docs/slides-template-replacement.md | 226 +++++++ internal/cmd/slides.go | 1 + internal/cmd/slides_create_from_template.go | 250 +++++++ .../cmd/slides_create_from_template_test.go | 637 ++++++++++++++++++ 6 files changed, 1137 insertions(+), 1 deletion(-) create mode 100644 docs/slides-template-replacement.md create mode 100644 internal/cmd/slides_create_from_template.go create mode 100644 internal/cmd/slides_create_from_template_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 106b65a..f5c07ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 account’s 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. diff --git a/README.md b/README.md index 6d9fcde..9dec03b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/slides-template-replacement.md b/docs/slides-template-replacement.md new file mode 100644 index 0000000..1ef2d0a --- /dev/null +++ b/docs/slides-template-replacement.md @@ -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" +``` diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index 9b6a9b1..0997d91 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -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"` diff --git a/internal/cmd/slides_create_from_template.go b/internal/cmd/slides_create_from_template.go new file mode 100644 index 0000000..9067e9c --- /dev/null +++ b/internal/cmd/slides_create_from_template.go @@ -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 +} diff --git a/internal/cmd/slides_create_from_template_test.go b/internal/cmd/slides_create_from_template_test.go new file mode 100644 index 0000000..7d75c91 --- /dev/null +++ b/internal/cmd/slides_create_from_template_test.go @@ -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) + } +}