From 17c5fb8b30975d2b5a57de31992ce2aa98fed45c Mon Sep 17 00:00:00 2001 From: Omri Ariav Date: Mon, 9 Feb 2026 13:59:08 -0600 Subject: [PATCH] feat: add slides speaker notes support (v1.11.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read and write support for Google Slides speaker notes via --notes flag. No extra API calls needed — Presentations.Get() already returns notes data. Read: --notes flag on info, list, and read commands includes speaker notes. Write: --notes mode on add-text and delete-text targets speaker notes shape via --slide-id or --slide-number. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- Makefile | 2 +- README.md | 10 +- ROADMAP.md | 4 + cmd/slides.go | 202 +++++++++++++-- cmd/slides_test.go | 356 ++++++++++++++++++++++++++- skills/slides/SKILL.md | 38 ++- skills/slides/references/commands.md | 39 ++- 8 files changed, 614 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab11676..a85b5a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ go run ./cmd/gws # or go run . ## Current Version -**v1.10.0** - Gmail label support. Adds `--include-labels` flag to `gmail list` for surfacing label IDs in thread output. +**v1.11.0** - Slides speaker notes support. Adds `--notes` flag to `slides info`, `list`, and `read` for reading speaker notes. Extends `slides add-text` and `delete-text` with `--notes` mode to write/clear speaker notes via `--slide-id` or `--slide-number`. ## Roadmap diff --git a/Makefile b/Makefile index 399caed..b6f1c83 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PKG := ./cmd/gws BUILD_DIR := ./bin # Version info -VERSION ?= 1.10.0 +VERSION ?= 1.11.0 COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS := -ldflags "-X github.com/omriariav/workspace-cli/cmd.Version=$(VERSION) \ diff --git a/README.md b/README.md index 7952e28..b4381bd 100644 --- a/README.md +++ b/README.md @@ -182,19 +182,19 @@ Add `--format text` to any command for human-readable output. | Command | Description | |---------|-------------| -| `gws slides info ` | Presentation metadata | -| `gws slides list ` | List slides with text content | -| `gws slides read [n]` | Read slide text (specific or all) | +| `gws slides info ` | Presentation metadata (`--notes` for speaker notes) | +| `gws slides list ` | List slides with text content (`--notes` for speaker notes) | +| `gws slides read [n]` | Read slide text (specific or all, `--notes` for speaker notes) | | `gws slides create` | Create new presentation (`--title`) | | `gws slides add-slide ` | Add slide (`--title`, `--body`, `--layout`) | | `gws slides delete-slide ` | Delete slide (`--slide-id` or `--slide-number`) | | `gws slides duplicate-slide ` | Duplicate slide (`--slide-id` or `--slide-number`) | | `gws slides add-shape ` | Add shape (`--slide-id/--slide-number`, `--type`, `--x`, `--y`, `--width`, `--height`) | | `gws slides add-image ` | Add image (`--slide-id/--slide-number`, `--url`, `--x`, `--y`, `--width`) | -| `gws slides add-text ` | Insert text into shape or table cell (`--object-id` or `--table-id`/`--row`/`--col`, `--text`, `--at`) | +| `gws slides add-text ` | Insert text into shape, table cell, or speaker notes (`--object-id`, `--table-id`/`--row`/`--col`, or `--notes`/`--slide-number`) | | `gws slides replace-text ` | Find and replace text (`--find`, `--replace`, `--match-case`) | | `gws slides delete-object ` | Delete any page element (`--object-id`) | -| `gws slides delete-text ` | Clear text from shape (`--object-id`, `--from`, `--to`) | +| `gws slides delete-text ` | Clear text from shape or speaker notes (`--object-id` or `--notes`/`--slide-number`) | | `gws slides update-text-style ` | Style text (`--object-id`, `--bold`, `--italic`, `--font-size`, `--color`) | | `gws slides update-transform ` | Move/scale/rotate element (`--object-id`, `--x`, `--y`, `--scale-x`, `--rotate`) | | `gws slides create-table ` | Add table (`--slide-id/--slide-number`, `--rows`, `--cols`) | diff --git a/ROADMAP.md b/ROADMAP.md index c5d5552..ec4e40d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,10 @@ Feature roadmap for the Google Workspace CLI. Items are organized by priority an ## Completed +### v1.11.0 +- [x] Slides: `--notes` flag on `info`, `list`, `read` to include speaker notes in output +- [x] Slides: `--notes` mode on `add-text` and `delete-text` to write/clear speaker notes (with `--slide-id` or `--slide-number`) + ### v0.7.0 - [x] `gws drive create-folder` - Create folder - [x] `gws drive move` - Move file to folder diff --git a/cmd/slides.go b/cmd/slides.go index b71c70e..6113e9c 100644 --- a/cmd/slides.go +++ b/cmd/slides.go @@ -254,6 +254,11 @@ func init() { slidesCmd.AddCommand(slidesUpdateShapeCmd) slidesCmd.AddCommand(slidesReorderSlidesCmd) + // Notes flags for read commands + slidesInfoCmd.Flags().Bool("notes", false, "Include speaker notes in output") + slidesListCmd.Flags().Bool("notes", false, "Include speaker notes in output") + slidesReadCmd.Flags().Bool("notes", false, "Include speaker notes in output") + // Create flags slidesCreateCmd.Flags().String("title", "", "Presentation title (required)") slidesCreateCmd.MarkFlagRequired("title") @@ -296,6 +301,9 @@ func init() { slidesAddTextCmd.Flags().Int("col", -1, "Column index, 0-based (required with --table-id)") slidesAddTextCmd.Flags().String("text", "", "Text to insert (required)") slidesAddTextCmd.Flags().Int("at", 0, "Position to insert at (0 = beginning)") + slidesAddTextCmd.Flags().Bool("notes", false, "Target speaker notes shape (mutually exclusive with --object-id and --table-id)") + slidesAddTextCmd.Flags().String("slide-id", "", "Slide object ID (required with --notes)") + slidesAddTextCmd.Flags().Int("slide-number", 0, "Slide number, 1-indexed (required with --notes)") slidesAddTextCmd.MarkFlagRequired("text") // Replace-text flags @@ -310,10 +318,12 @@ func init() { slidesDeleteObjectCmd.MarkFlagRequired("object-id") // Delete-text flags - slidesDeleteTextCmd.Flags().String("object-id", "", "Shape containing text (required)") + slidesDeleteTextCmd.Flags().String("object-id", "", "Shape containing text (required unless --notes)") slidesDeleteTextCmd.Flags().Int("from", 0, "Start index (default 0)") slidesDeleteTextCmd.Flags().Int("to", -1, "End index (if omitted, deletes to end)") - slidesDeleteTextCmd.MarkFlagRequired("object-id") + slidesDeleteTextCmd.Flags().Bool("notes", false, "Target speaker notes shape (alternative to --object-id)") + slidesDeleteTextCmd.Flags().String("slide-id", "", "Slide object ID (required with --notes)") + slidesDeleteTextCmd.Flags().Int("slide-number", 0, "Slide number, 1-indexed (required with --notes)") // Update-text-style flags slidesUpdateTextStyleCmd.Flags().String("object-id", "", "Shape containing text (required)") @@ -442,6 +452,8 @@ func runSlidesInfo(cmd *cobra.Command, args []string) error { } } + includeNotes, _ := cmd.Flags().GetBool("notes") + // List slide IDs and titles slideInfo := make([]map[string]interface{}, 0, len(presentation.Slides)) for i, slide := range presentation.Slides { @@ -456,6 +468,13 @@ func runSlidesInfo(cmd *cobra.Command, args []string) error { info["title"] = title } + if includeNotes { + notes := extractSpeakerNotes(slide) + if notes != "" { + info["notes"] = notes + } + } + slideInfo = append(slideInfo, info) } result["slides"] = slideInfo @@ -484,6 +503,8 @@ func runSlidesList(cmd *cobra.Command, args []string) error { return p.PrintError(fmt.Errorf("failed to get presentation: %w", err)) } + includeNotes, _ := cmd.Flags().GetBool("notes") + slidesList := make([]map[string]interface{}, 0, len(presentation.Slides)) for i, slide := range presentation.Slides { slideData := map[string]interface{}{ @@ -500,6 +521,13 @@ func runSlidesList(cmd *cobra.Command, args []string) error { // Count elements slideData["element_count"] = len(slide.PageElements) + if includeNotes { + notes := extractSpeakerNotes(slide) + if notes != "" { + slideData["notes"] = notes + } + } + slidesList = append(slidesList, slideData) } @@ -531,6 +559,8 @@ func runSlidesRead(cmd *cobra.Command, args []string) error { return p.PrintError(fmt.Errorf("failed to get presentation: %w", err)) } + includeNotes, _ := cmd.Flags().GetBool("notes") + // If slide number provided, read specific slide if len(args) > 1 { var slideNum int @@ -543,13 +573,22 @@ func runSlidesRead(cmd *cobra.Command, args []string) error { slide := presentation.Slides[slideNum-1] text := extractSlideText(slide) - return p.Print(map[string]interface{}{ + result := map[string]interface{}{ "slide": slideNum, "id": slide.ObjectId, "text": text, "title": extractSlideTitle(slide), "layout": slide.SlideProperties.LayoutObjectId, - }) + } + + if includeNotes { + notes := extractSpeakerNotes(slide) + if notes != "" { + result["notes"] = notes + } + } + + return p.Print(result) } // Read all slides @@ -566,6 +605,13 @@ func runSlidesRead(cmd *cobra.Command, args []string) error { slideData["title"] = title } + if includeNotes { + notes := extractSpeakerNotes(slide) + if notes != "" { + slideData["notes"] = notes + } + } + slidesContent = append(slidesContent, slideData) } @@ -649,6 +695,59 @@ func extractTableText(table *slides.Table) string { return strings.Join(rows, "\n") } +// extractSpeakerNotes extracts speaker notes text from a slide's notes page. +func extractSpeakerNotes(slide *slides.Page) string { + if slide.SlideProperties == nil { + return "" + } + notesPage := slide.SlideProperties.NotesPage + if notesPage == nil { + return "" + } + if notesPage.NotesProperties == nil || notesPage.NotesProperties.SpeakerNotesObjectId == "" { + return "" + } + + notesObjectID := notesPage.NotesProperties.SpeakerNotesObjectId + for _, element := range notesPage.PageElements { + if element.ObjectId == notesObjectID && element.Shape != nil { + return extractShapeText(element.Shape) + } + } + return "" +} + +// getSpeakerNotesObjectID returns the object ID of the speaker notes shape for a slide. +func getSpeakerNotesObjectID(slide *slides.Page) (string, error) { + if slide.SlideProperties == nil || slide.SlideProperties.NotesPage == nil { + return "", fmt.Errorf("slide has no notes page") + } + notesPage := slide.SlideProperties.NotesPage + if notesPage.NotesProperties == nil || notesPage.NotesProperties.SpeakerNotesObjectId == "" { + return "", fmt.Errorf("slide has no speaker notes shape") + } + return notesPage.NotesProperties.SpeakerNotesObjectId, nil +} + +// findSlide resolves a slide from a presentation by --slide-id or --slide-number. +func findSlide(presentation *slides.Presentation, slideIDFlag string, slideNumber int) (*slides.Page, error) { + if slideIDFlag != "" && slideNumber > 0 { + return nil, fmt.Errorf("specify only one of --slide-id or --slide-number, not both") + } + if slideIDFlag != "" { + for _, s := range presentation.Slides { + if s.ObjectId == slideIDFlag { + return s, nil + } + } + return nil, fmt.Errorf("slide with ID '%s' not found", slideIDFlag) + } + if slideNumber < 1 || slideNumber > len(presentation.Slides) { + return nil, fmt.Errorf("slide number %d out of range (1-%d)", slideNumber, len(presentation.Slides)) + } + return presentation.Slides[slideNumber-1], nil +} + func runSlidesCreate(cmd *cobra.Command, args []string) error { p := printer.New(os.Stdout, GetFormat()) ctx := context.Background() @@ -1264,13 +1363,26 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error { col, _ := cmd.Flags().GetInt("col") text, _ := cmd.Flags().GetString("text") insertionIndex, _ := cmd.Flags().GetInt("at") + notesMode, _ := cmd.Flags().GetBool("notes") + slideIDFlag, _ := cmd.Flags().GetString("slide-id") + slideNumber, _ := cmd.Flags().GetInt("slide-number") // Validate mutually exclusive flags (fail fast before network calls) - if objectID != "" && tableID != "" { - return p.PrintError(fmt.Errorf("cannot specify both --object-id and --table-id")) + modeCount := 0 + if objectID != "" { + modeCount++ + } + if tableID != "" { + modeCount++ + } + if notesMode { + modeCount++ } - if objectID == "" && tableID == "" { - return p.PrintError(fmt.Errorf("must specify either --object-id or --table-id")) + if modeCount > 1 { + return p.PrintError(fmt.Errorf("--object-id, --table-id, and --notes are mutually exclusive")) + } + if modeCount == 0 { + return p.PrintError(fmt.Errorf("must specify --object-id, --table-id, or --notes")) } // Validate table cell mode requires row and col @@ -1283,6 +1395,11 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error { } } + // Validate notes mode requires slide targeting + if notesMode && slideIDFlag == "" && slideNumber == 0 { + return p.PrintError(fmt.Errorf("--notes requires --slide-id or --slide-number")) + } + // Now create the client after validation passes ctx := context.Background() factory, err := client.NewFactory(ctx) @@ -1295,6 +1412,25 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error { return p.PrintError(err) } + // Resolve notes mode to an object ID + if notesMode { + presentation, err := svc.Presentations.Get(presentationID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get presentation: %w", err)) + } + + slide, err := findSlide(presentation, slideIDFlag, slideNumber) + if err != nil { + return p.PrintError(err) + } + + notesObjID, err := getSpeakerNotesObjectID(slide) + if err != nil { + return p.PrintError(fmt.Errorf("cannot target speaker notes: %w", err)) + } + objectID = notesObjID + } + // Build the InsertText request insertTextReq := &slides.InsertTextRequest{ Text: text, @@ -1319,9 +1455,12 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error { result["row"] = row result["col"] = col } else { - // Shape/text box mode + // Shape/text box mode (including resolved notes mode) insertTextReq.ObjectId = objectID result["object_id"] = objectID + if notesMode { + result["target"] = "speaker_notes" + } } requests := []*slides.Request{ @@ -1453,8 +1592,29 @@ func runSlidesDeleteObject(cmd *cobra.Command, args []string) error { func runSlidesDeleteText(cmd *cobra.Command, args []string) error { p := printer.New(os.Stdout, GetFormat()) - ctx := context.Background() + presentationID := args[0] + objectID, _ := cmd.Flags().GetString("object-id") + fromIndex, _ := cmd.Flags().GetInt("from") + toIndex, _ := cmd.Flags().GetInt("to") + notesMode, _ := cmd.Flags().GetBool("notes") + slideIDFlag, _ := cmd.Flags().GetString("slide-id") + slideNumber, _ := cmd.Flags().GetInt("slide-number") + + // Validate: need either --object-id or --notes + if objectID == "" && !notesMode { + return p.PrintError(fmt.Errorf("must specify --object-id or --notes")) + } + if objectID != "" && notesMode { + return p.PrintError(fmt.Errorf("--object-id and --notes are mutually exclusive")) + } + + // Validate notes mode requires slide targeting + if notesMode && slideIDFlag == "" && slideNumber == 0 { + return p.PrintError(fmt.Errorf("--notes requires --slide-id or --slide-number")) + } + + ctx := context.Background() factory, err := client.NewFactory(ctx) if err != nil { return p.PrintError(err) @@ -1465,10 +1625,24 @@ func runSlidesDeleteText(cmd *cobra.Command, args []string) error { return p.PrintError(err) } - presentationID := args[0] - objectID, _ := cmd.Flags().GetString("object-id") - fromIndex, _ := cmd.Flags().GetInt("from") - toIndex, _ := cmd.Flags().GetInt("to") + // Resolve notes mode to an object ID + if notesMode { + presentation, err := svc.Presentations.Get(presentationID).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to get presentation: %w", err)) + } + + slide, err := findSlide(presentation, slideIDFlag, slideNumber) + if err != nil { + return p.PrintError(err) + } + + notesObjID, err := getSpeakerNotesObjectID(slide) + if err != nil { + return p.PrintError(fmt.Errorf("cannot target speaker notes: %w", err)) + } + objectID = notesObjID + } startIdx := int64(fromIndex) textRange := &slides.Range{ diff --git a/cmd/slides_test.go b/cmd/slides_test.go index ce357fb..bf62fff 100644 --- a/cmd/slides_test.go +++ b/cmd/slides_test.go @@ -732,14 +732,14 @@ func TestSlidesAddTextCommand_ValidationErrors(t *testing.T) { "table-id": "table-456", "text": "test", }, - expectedError: "cannot specify both --object-id and --table-id", + expectedError: "--object-id, --table-id, and --notes are mutually exclusive", }, { name: "missing both object-id and table-id", flags: map[string]string{ "text": "test", }, - expectedError: "must specify either --object-id or --table-id", + expectedError: "must specify --object-id, --table-id, or --notes", }, { name: "missing row with table-id", @@ -775,6 +775,9 @@ func TestSlidesAddTextCommand_ValidationErrors(t *testing.T) { cmd.Flags().Set("col", "-1") cmd.Flags().Set("text", "") cmd.Flags().Set("at", "0") + cmd.Flags().Set("notes", "false") + cmd.Flags().Set("slide-id", "") + cmd.Flags().Set("slide-number", "0") // Set test flags for flag, value := range tt.flags { @@ -804,6 +807,355 @@ func TestSlidesAddTextCommand_ValidationErrors(t *testing.T) { } } +// TestExtractSpeakerNotes tests extracting text from speaker notes +func TestExtractSpeakerNotes(t *testing.T) { + slide := &slides.Page{ + ObjectId: "slide-1", + SlideProperties: &slides.SlideProperties{ + NotesPage: &slides.Page{ + NotesProperties: &slides.NotesProperties{ + SpeakerNotesObjectId: "notes-shape-1", + }, + PageElements: []*slides.PageElement{ + { + ObjectId: "notes-shape-1", + Shape: &slides.Shape{ + Text: &slides.TextContent{ + TextElements: []*slides.TextElement{ + {TextRun: &slides.TextRun{Content: "These are my speaker notes"}}, + }, + }, + }, + }, + }, + }, + }, + } + + notes := extractSpeakerNotes(slide) + if notes != "These are my speaker notes" { + t.Errorf("expected 'These are my speaker notes', got '%s'", notes) + } +} + +// TestExtractSpeakerNotes_Empty tests nil/missing notes page +func TestExtractSpeakerNotes_Empty(t *testing.T) { + tests := []struct { + name string + slide *slides.Page + }{ + {"nil SlideProperties", &slides.Page{ObjectId: "s1"}}, + {"nil NotesPage", &slides.Page{ + ObjectId: "s2", + SlideProperties: &slides.SlideProperties{}, + }}, + {"nil NotesProperties", &slides.Page{ + ObjectId: "s3", + SlideProperties: &slides.SlideProperties{ + NotesPage: &slides.Page{}, + }, + }}, + {"empty SpeakerNotesObjectId", &slides.Page{ + ObjectId: "s4", + SlideProperties: &slides.SlideProperties{ + NotesPage: &slides.Page{ + NotesProperties: &slides.NotesProperties{ + SpeakerNotesObjectId: "", + }, + }, + }, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + notes := extractSpeakerNotes(tt.slide) + if notes != "" { + t.Errorf("expected empty string, got '%s'", notes) + } + }) + } +} + +// TestGetSpeakerNotesObjectID tests successful retrieval +func TestGetSpeakerNotesObjectID(t *testing.T) { + slide := &slides.Page{ + SlideProperties: &slides.SlideProperties{ + NotesPage: &slides.Page{ + NotesProperties: &slides.NotesProperties{ + SpeakerNotesObjectId: "notes-shape-abc", + }, + }, + }, + } + + id, err := getSpeakerNotesObjectID(slide) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != "notes-shape-abc" { + t.Errorf("expected 'notes-shape-abc', got '%s'", id) + } +} + +// TestGetSpeakerNotesObjectID_NoNotesPage tests error case +func TestGetSpeakerNotesObjectID_NoNotesPage(t *testing.T) { + tests := []struct { + name string + slide *slides.Page + }{ + {"nil SlideProperties", &slides.Page{}}, + {"nil NotesPage", &slides.Page{ + SlideProperties: &slides.SlideProperties{}, + }}, + {"nil NotesProperties", &slides.Page{ + SlideProperties: &slides.SlideProperties{ + NotesPage: &slides.Page{}, + }, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := getSpeakerNotesObjectID(tt.slide) + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +// TestSlidesReadCommand_NotesFlag tests that --notes flag exists on read +func TestSlidesReadCommand_NotesFlag(t *testing.T) { + cmd := findSubcommand(slidesCmd, "read") + if cmd == nil { + t.Fatal("slides read command not found") + } + if cmd.Flags().Lookup("notes") == nil { + t.Error("expected --notes flag on read command") + } +} + +// TestSlidesListCommand_NotesFlag tests that --notes flag exists on list +func TestSlidesListCommand_NotesFlag(t *testing.T) { + cmd := findSubcommand(slidesCmd, "list") + if cmd == nil { + t.Fatal("slides list command not found") + } + if cmd.Flags().Lookup("notes") == nil { + t.Error("expected --notes flag on list command") + } +} + +// TestSlidesInfoCommand_NotesFlag tests that --notes flag exists on info +func TestSlidesInfoCommand_NotesFlag(t *testing.T) { + cmd := findSubcommand(slidesCmd, "info") + if cmd == nil { + t.Fatal("slides info command not found") + } + if cmd.Flags().Lookup("notes") == nil { + t.Error("expected --notes flag on info command") + } +} + +// TestSlidesAddTextCommand_NotesValidation tests notes mode mutual exclusivity +func TestSlidesAddTextCommand_NotesValidation(t *testing.T) { + tests := []struct { + name string + flags map[string]string + expectedError string + }{ + { + name: "notes with object-id", + flags: map[string]string{ + "object-id": "shape-123", + "notes": "true", + "text": "test", + }, + expectedError: "--object-id, --table-id, and --notes are mutually exclusive", + }, + { + name: "notes with table-id", + flags: map[string]string{ + "table-id": "table-456", + "notes": "true", + "text": "test", + }, + expectedError: "--object-id, --table-id, and --notes are mutually exclusive", + }, + { + name: "notes without slide targeting", + flags: map[string]string{ + "notes": "true", + "text": "test", + }, + expectedError: "--notes requires --slide-id or --slide-number", + }, + { + name: "no mode specified", + flags: map[string]string{ + "text": "test", + }, + expectedError: "must specify --object-id, --table-id, or --notes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := findSubcommand(slidesCmd, "add-text") + if cmd == nil { + t.Fatal("slides add-text command not found") + } + + // Reset flags to defaults + cmd.Flags().Set("object-id", "") + cmd.Flags().Set("table-id", "") + cmd.Flags().Set("row", "-1") + cmd.Flags().Set("col", "-1") + cmd.Flags().Set("text", "") + cmd.Flags().Set("at", "0") + cmd.Flags().Set("notes", "false") + cmd.Flags().Set("slide-id", "") + cmd.Flags().Set("slide-number", "0") + + for flag, value := range tt.flags { + if err := cmd.Flags().Set(flag, value); err != nil { + t.Fatalf("failed to set flag --%s: %v", flag, err) + } + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + _ = cmd.RunE(cmd, []string{"test-presentation-id"}) + + w.Close() + os.Stdout = oldStdout + captured, _ := io.ReadAll(r) + output := string(captured) + + if !strings.Contains(output, tt.expectedError) { + t.Errorf("expected output containing %q, got %q", tt.expectedError, output) + } + }) + } +} + +// TestSlidesDeleteTextCommand_NotesValidation tests delete-text notes validation +func TestSlidesDeleteTextCommand_NotesValidation(t *testing.T) { + tests := []struct { + name string + flags map[string]string + expectedError string + }{ + { + name: "notes with object-id", + flags: map[string]string{ + "object-id": "shape-123", + "notes": "true", + }, + expectedError: "--object-id and --notes are mutually exclusive", + }, + { + name: "notes without slide targeting", + flags: map[string]string{ + "notes": "true", + }, + expectedError: "--notes requires --slide-id or --slide-number", + }, + { + name: "no mode specified", + flags: map[string]string{}, + expectedError: "must specify --object-id or --notes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := findSubcommand(slidesCmd, "delete-text") + if cmd == nil { + t.Fatal("slides delete-text command not found") + } + + // Reset flags to defaults + cmd.Flags().Set("object-id", "") + cmd.Flags().Set("from", "0") + cmd.Flags().Set("to", "-1") + cmd.Flags().Set("notes", "false") + cmd.Flags().Set("slide-id", "") + cmd.Flags().Set("slide-number", "0") + + for flag, value := range tt.flags { + if err := cmd.Flags().Set(flag, value); err != nil { + t.Fatalf("failed to set flag --%s: %v", flag, err) + } + } + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + _ = cmd.RunE(cmd, []string{"test-presentation-id"}) + + w.Close() + os.Stdout = oldStdout + captured, _ := io.ReadAll(r) + output := string(captured) + + if !strings.Contains(output, tt.expectedError) { + t.Errorf("expected output containing %q, got %q", tt.expectedError, output) + } + }) + } +} + +// TestFindSlide tests the findSlide helper +func TestFindSlide(t *testing.T) { + pres := &slides.Presentation{ + Slides: []*slides.Page{ + {ObjectId: "slide-a"}, + {ObjectId: "slide-b"}, + }, + } + + // By slide ID + s, err := findSlide(pres, "slide-b", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.ObjectId != "slide-b" { + t.Errorf("expected slide-b, got %s", s.ObjectId) + } + + // By slide number + s, err = findSlide(pres, "", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.ObjectId != "slide-a" { + t.Errorf("expected slide-a, got %s", s.ObjectId) + } + + // Both specified + _, err = findSlide(pres, "slide-a", 1) + if err == nil { + t.Error("expected error when both slide-id and slide-number specified") + } + + // Not found + _, err = findSlide(pres, "nonexistent", 0) + if err == nil { + t.Error("expected error for nonexistent slide ID") + } + + // Out of range + _, err = findSlide(pres, "", 99) + if err == nil { + t.Error("expected error for out-of-range slide number") + } +} + // TestSlidesReplaceTextCommand_Flags tests replace-text command flags func TestSlidesReplaceTextCommand_Flags(t *testing.T) { cmd := findSubcommand(slidesCmd, "replace-text") diff --git a/skills/slides/SKILL.md b/skills/slides/SKILL.md index 50b5351..a045bce 100644 --- a/skills/slides/SKILL.md +++ b/skills/slides/SKILL.md @@ -1,6 +1,6 @@ --- name: gws-slides -version: 1.3.0 +version: 1.4.0 description: "Google Slides CLI operations via gws. Use when users need to create, read, or edit Google Slides presentations. Triggers: slides, presentation, google slides, deck." metadata: short-description: Google Slides CLI operations @@ -36,6 +36,7 @@ For initial setup, see the `gws-auth` skill. | List all slides | `gws slides list ` | | Read slide content | `gws slides read ` | | Read specific slide | `gws slides read 3` | +| Read with speaker notes | `gws slides read --notes` | | Create presentation | `gws slides create --title "My Deck"` | | Add a slide | `gws slides add-slide --title "Slide Title" --body "Content"` | | Add blank slide | `gws slides add-slide --layout BLANK` | @@ -45,6 +46,8 @@ For initial setup, see the `gws-auth` skill. | Add an image | `gws slides add-image --slide-number 1 --url "https://..."` | | Add text to shape | `gws slides add-text --object-id --text "Hello"` | | Add text to table cell | `gws slides add-text --table-id --row 0 --col 0 --text "Cell"` | +| Add speaker notes | `gws slides add-text --notes --slide-number 1 --text "Notes here"` | +| Clear speaker notes | `gws slides delete-text --notes --slide-number 1` | | Find and replace | `gws slides replace-text --find "old" --replace "new"` | | Delete any element | `gws slides delete-object --object-id ` | | Clear text from shape | `gws slides delete-text --object-id ` | @@ -64,25 +67,34 @@ For initial setup, see the `gws-auth` skill. ### info — Get presentation info ```bash -gws slides info +gws slides info [--notes] ``` +**Flags:** +- `--notes` — Include speaker notes in output + ### list — List all slides ```bash -gws slides list +gws slides list [--notes] ``` Lists all slides with their content and object IDs. +**Flags:** +- `--notes` — Include speaker notes in output + ### read — Read slide content ```bash -gws slides read [slide-number] +gws slides read [slide-number] [--notes] ``` Reads text content. Omit slide number to read all slides. Slide numbers are **1-indexed**. +**Flags:** +- `--notes` — Include speaker notes in output + ### create — Create a presentation ```bash @@ -153,7 +165,7 @@ gws slides add-image --url [flags] - `--y float` — Y position in points (default: 100) - `--width float` — Width in points (default: 400; height auto-calculated) -### add-text — Add text to shape or table cell +### add-text — Add text to shape, table cell, or speaker notes ```bash # For shapes/text boxes: @@ -161,13 +173,19 @@ gws slides add-text --object-id --text [flags] # For table cells: gws slides add-text --table-id --row --col --text [flags] + +# For speaker notes: +gws slides add-text --notes --slide-number --text [flags] ``` **Flags:** -- `--object-id string` — Shape/text box ID (mutually exclusive with --table-id) +- `--object-id string` — Shape/text box ID (mutually exclusive with --table-id and --notes) - `--table-id string` — Table object ID (requires --row and --col) - `--row int` — Row index, 0-based (required with --table-id) - `--col int` — Column index, 0-based (required with --table-id) +- `--notes` — Target speaker notes shape (mutually exclusive with --object-id and --table-id) +- `--slide-id string` — Slide object ID (required with --notes) +- `--slide-number int` — Slide number, 1-indexed (required with --notes) - `--text string` — Text to insert (required) - `--at int` — Position to insert at (0 = beginning) @@ -194,14 +212,18 @@ gws slides delete-object --object-id Deletes shapes, images, tables, or any page element by object ID. -### delete-text — Clear text from shape +### delete-text — Clear text from shape or speaker notes ```bash gws slides delete-text --object-id [flags] +gws slides delete-text --notes --slide-number [flags] ``` **Flags:** -- `--object-id string` — Shape containing text (required) +- `--object-id string` — Shape containing text (required unless --notes) +- `--notes` — Target speaker notes shape (alternative to --object-id) +- `--slide-id string` — Slide object ID (required with --notes) +- `--slide-number int` — Slide number, 1-indexed (required with --notes) - `--from int` — Start index (default: 0) - `--to int` — End index (if omitted, deletes to end) diff --git a/skills/slides/references/commands.md b/skills/slides/references/commands.md index d5bf07d..9994adc 100644 --- a/skills/slides/references/commands.md +++ b/skills/slides/references/commands.md @@ -19,9 +19,13 @@ Complete flag and option reference for `gws slides` commands. Gets metadata about a Google Slides presentation. ``` -Usage: gws slides info +Usage: gws slides info [flags] ``` +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--notes` | bool | `false` | Include speaker notes in output | + --- ## gws slides list @@ -29,9 +33,13 @@ Usage: gws slides info Lists all slides in a presentation with their content and object IDs. ``` -Usage: gws slides list +Usage: gws slides list [flags] ``` +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--notes` | bool | `false` | Include speaker notes in output | + Returns slide details including object IDs for elements — needed for `add-text`. --- @@ -41,9 +49,13 @@ Returns slide details including object IDs for elements — needed for `add-text Reads the text content of a specific slide or all slides. ``` -Usage: gws slides read [slide-number] +Usage: gws slides read [slide-number] [flags] ``` +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--notes` | bool | `false` | Include speaker notes in output | + Slide numbers are **1-indexed**. Omit the slide number to read all slides. --- @@ -178,7 +190,7 @@ Height is automatically calculated to maintain aspect ratio based on width. ## gws slides add-text -Inserts text into an existing shape or text box. +Inserts text into an existing shape, text box, table cell, or speaker notes. ``` Usage: gws slides add-text [flags] @@ -186,11 +198,17 @@ Usage: gws slides add-text [flags] | Flag | Type | Default | Required | Description | |------|------|---------|----------|-------------| -| `--object-id` | string | | Yes | Object ID to insert text into | +| `--object-id` | string | | | Object ID to insert text into (mutually exclusive with --table-id and --notes) | +| `--table-id` | string | | | Table object ID (requires --row and --col) | +| `--row` | int | -1 | | Row index, 0-based (required with --table-id) | +| `--col` | int | -1 | | Column index, 0-based (required with --table-id) | +| `--notes` | bool | false | | Target speaker notes (mutually exclusive with --object-id and --table-id) | +| `--slide-id` | string | | | Slide object ID (required with --notes) | +| `--slide-number` | int | 0 | | Slide number, 1-indexed (required with --notes) | | `--text` | string | | Yes | Text to insert | | `--at` | int | 0 | No | Position to insert at (0 = beginning) | -Get object IDs from `gws slides list ` output. +One of `--object-id`, `--table-id`, or `--notes` is required. Get object IDs from `gws slides list ` output. --- @@ -230,7 +248,7 @@ Get object IDs from `gws slides list ` output. ## gws slides delete-text -Clears text from a shape, optionally within a specific range. +Clears text from a shape or speaker notes, optionally within a specific range. ``` Usage: gws slides delete-text [flags] @@ -238,10 +256,15 @@ Usage: gws slides delete-text [flags] | Flag | Type | Default | Required | Description | |------|------|---------|----------|-------------| -| `--object-id` | string | | Yes | Shape containing text | +| `--object-id` | string | | | Shape containing text (required unless --notes) | +| `--notes` | bool | false | | Target speaker notes (alternative to --object-id) | +| `--slide-id` | string | | | Slide object ID (required with --notes) | +| `--slide-number` | int | 0 | | Slide number, 1-indexed (required with --notes) | | `--from` | int | 0 | No | Start index | | `--to` | int | | No | End index (if omitted, deletes to end) | +One of `--object-id` or `--notes` is required. + --- ## gws slides update-text-style