Skip to content

Commit 17c5fb8

Browse files
omriariavclaude
andcommitted
feat: add slides speaker notes support (v1.11.0)
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 <noreply@anthropic.com>
1 parent 03aaea9 commit 17c5fb8

File tree

8 files changed

+614
-39
lines changed

8 files changed

+614
-39
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ go run ./cmd/gws # or go run .
4545

4646
## Current Version
4747

48-
**v1.10.0** - Gmail label support. Adds `--include-labels` flag to `gmail list` for surfacing label IDs in thread output.
48+
**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`.
4949

5050
## Roadmap
5151

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ PKG := ./cmd/gws
33
BUILD_DIR := ./bin
44

55
# Version info
6-
VERSION ?= 1.10.0
6+
VERSION ?= 1.11.0
77
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
88
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
99
LDFLAGS := -ldflags "-X github.com/omriariav/workspace-cli/cmd.Version=$(VERSION) \

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,19 +182,19 @@ Add `--format text` to any command for human-readable output.
182182

183183
| Command | Description |
184184
|---------|-------------|
185-
| `gws slides info <id>` | Presentation metadata |
186-
| `gws slides list <id>` | List slides with text content |
187-
| `gws slides read <id> [n]` | Read slide text (specific or all) |
185+
| `gws slides info <id>` | Presentation metadata (`--notes` for speaker notes) |
186+
| `gws slides list <id>` | List slides with text content (`--notes` for speaker notes) |
187+
| `gws slides read <id> [n]` | Read slide text (specific or all, `--notes` for speaker notes) |
188188
| `gws slides create` | Create new presentation (`--title`) |
189189
| `gws slides add-slide <id>` | Add slide (`--title`, `--body`, `--layout`) |
190190
| `gws slides delete-slide <id>` | Delete slide (`--slide-id` or `--slide-number`) |
191191
| `gws slides duplicate-slide <id>` | Duplicate slide (`--slide-id` or `--slide-number`) |
192192
| `gws slides add-shape <id>` | Add shape (`--slide-id/--slide-number`, `--type`, `--x`, `--y`, `--width`, `--height`) |
193193
| `gws slides add-image <id>` | Add image (`--slide-id/--slide-number`, `--url`, `--x`, `--y`, `--width`) |
194-
| `gws slides add-text <id>` | Insert text into shape or table cell (`--object-id` or `--table-id`/`--row`/`--col`, `--text`, `--at`) |
194+
| `gws slides add-text <id>` | Insert text into shape, table cell, or speaker notes (`--object-id`, `--table-id`/`--row`/`--col`, or `--notes`/`--slide-number`) |
195195
| `gws slides replace-text <id>` | Find and replace text (`--find`, `--replace`, `--match-case`) |
196196
| `gws slides delete-object <id>` | Delete any page element (`--object-id`) |
197-
| `gws slides delete-text <id>` | Clear text from shape (`--object-id`, `--from`, `--to`) |
197+
| `gws slides delete-text <id>` | Clear text from shape or speaker notes (`--object-id` or `--notes`/`--slide-number`) |
198198
| `gws slides update-text-style <id>` | Style text (`--object-id`, `--bold`, `--italic`, `--font-size`, `--color`) |
199199
| `gws slides update-transform <id>` | Move/scale/rotate element (`--object-id`, `--x`, `--y`, `--scale-x`, `--rotate`) |
200200
| `gws slides create-table <id>` | Add table (`--slide-id/--slide-number`, `--rows`, `--cols`) |

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Feature roadmap for the Google Workspace CLI. Items are organized by priority an
1313

1414
## Completed
1515

16+
### v1.11.0
17+
- [x] Slides: `--notes` flag on `info`, `list`, `read` to include speaker notes in output
18+
- [x] Slides: `--notes` mode on `add-text` and `delete-text` to write/clear speaker notes (with `--slide-id` or `--slide-number`)
19+
1620
### v0.7.0
1721
- [x] `gws drive create-folder` - Create folder
1822
- [x] `gws drive move` - Move file to folder

cmd/slides.go

Lines changed: 188 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@ func init() {
254254
slidesCmd.AddCommand(slidesUpdateShapeCmd)
255255
slidesCmd.AddCommand(slidesReorderSlidesCmd)
256256

257+
// Notes flags for read commands
258+
slidesInfoCmd.Flags().Bool("notes", false, "Include speaker notes in output")
259+
slidesListCmd.Flags().Bool("notes", false, "Include speaker notes in output")
260+
slidesReadCmd.Flags().Bool("notes", false, "Include speaker notes in output")
261+
257262
// Create flags
258263
slidesCreateCmd.Flags().String("title", "", "Presentation title (required)")
259264
slidesCreateCmd.MarkFlagRequired("title")
@@ -296,6 +301,9 @@ func init() {
296301
slidesAddTextCmd.Flags().Int("col", -1, "Column index, 0-based (required with --table-id)")
297302
slidesAddTextCmd.Flags().String("text", "", "Text to insert (required)")
298303
slidesAddTextCmd.Flags().Int("at", 0, "Position to insert at (0 = beginning)")
304+
slidesAddTextCmd.Flags().Bool("notes", false, "Target speaker notes shape (mutually exclusive with --object-id and --table-id)")
305+
slidesAddTextCmd.Flags().String("slide-id", "", "Slide object ID (required with --notes)")
306+
slidesAddTextCmd.Flags().Int("slide-number", 0, "Slide number, 1-indexed (required with --notes)")
299307
slidesAddTextCmd.MarkFlagRequired("text")
300308

301309
// Replace-text flags
@@ -310,10 +318,12 @@ func init() {
310318
slidesDeleteObjectCmd.MarkFlagRequired("object-id")
311319

312320
// Delete-text flags
313-
slidesDeleteTextCmd.Flags().String("object-id", "", "Shape containing text (required)")
321+
slidesDeleteTextCmd.Flags().String("object-id", "", "Shape containing text (required unless --notes)")
314322
slidesDeleteTextCmd.Flags().Int("from", 0, "Start index (default 0)")
315323
slidesDeleteTextCmd.Flags().Int("to", -1, "End index (if omitted, deletes to end)")
316-
slidesDeleteTextCmd.MarkFlagRequired("object-id")
324+
slidesDeleteTextCmd.Flags().Bool("notes", false, "Target speaker notes shape (alternative to --object-id)")
325+
slidesDeleteTextCmd.Flags().String("slide-id", "", "Slide object ID (required with --notes)")
326+
slidesDeleteTextCmd.Flags().Int("slide-number", 0, "Slide number, 1-indexed (required with --notes)")
317327

318328
// Update-text-style flags
319329
slidesUpdateTextStyleCmd.Flags().String("object-id", "", "Shape containing text (required)")
@@ -442,6 +452,8 @@ func runSlidesInfo(cmd *cobra.Command, args []string) error {
442452
}
443453
}
444454

455+
includeNotes, _ := cmd.Flags().GetBool("notes")
456+
445457
// List slide IDs and titles
446458
slideInfo := make([]map[string]interface{}, 0, len(presentation.Slides))
447459
for i, slide := range presentation.Slides {
@@ -456,6 +468,13 @@ func runSlidesInfo(cmd *cobra.Command, args []string) error {
456468
info["title"] = title
457469
}
458470

471+
if includeNotes {
472+
notes := extractSpeakerNotes(slide)
473+
if notes != "" {
474+
info["notes"] = notes
475+
}
476+
}
477+
459478
slideInfo = append(slideInfo, info)
460479
}
461480
result["slides"] = slideInfo
@@ -484,6 +503,8 @@ func runSlidesList(cmd *cobra.Command, args []string) error {
484503
return p.PrintError(fmt.Errorf("failed to get presentation: %w", err))
485504
}
486505

506+
includeNotes, _ := cmd.Flags().GetBool("notes")
507+
487508
slidesList := make([]map[string]interface{}, 0, len(presentation.Slides))
488509
for i, slide := range presentation.Slides {
489510
slideData := map[string]interface{}{
@@ -500,6 +521,13 @@ func runSlidesList(cmd *cobra.Command, args []string) error {
500521
// Count elements
501522
slideData["element_count"] = len(slide.PageElements)
502523

524+
if includeNotes {
525+
notes := extractSpeakerNotes(slide)
526+
if notes != "" {
527+
slideData["notes"] = notes
528+
}
529+
}
530+
503531
slidesList = append(slidesList, slideData)
504532
}
505533

@@ -531,6 +559,8 @@ func runSlidesRead(cmd *cobra.Command, args []string) error {
531559
return p.PrintError(fmt.Errorf("failed to get presentation: %w", err))
532560
}
533561

562+
includeNotes, _ := cmd.Flags().GetBool("notes")
563+
534564
// If slide number provided, read specific slide
535565
if len(args) > 1 {
536566
var slideNum int
@@ -543,13 +573,22 @@ func runSlidesRead(cmd *cobra.Command, args []string) error {
543573
slide := presentation.Slides[slideNum-1]
544574
text := extractSlideText(slide)
545575

546-
return p.Print(map[string]interface{}{
576+
result := map[string]interface{}{
547577
"slide": slideNum,
548578
"id": slide.ObjectId,
549579
"text": text,
550580
"title": extractSlideTitle(slide),
551581
"layout": slide.SlideProperties.LayoutObjectId,
552-
})
582+
}
583+
584+
if includeNotes {
585+
notes := extractSpeakerNotes(slide)
586+
if notes != "" {
587+
result["notes"] = notes
588+
}
589+
}
590+
591+
return p.Print(result)
553592
}
554593

555594
// Read all slides
@@ -566,6 +605,13 @@ func runSlidesRead(cmd *cobra.Command, args []string) error {
566605
slideData["title"] = title
567606
}
568607

608+
if includeNotes {
609+
notes := extractSpeakerNotes(slide)
610+
if notes != "" {
611+
slideData["notes"] = notes
612+
}
613+
}
614+
569615
slidesContent = append(slidesContent, slideData)
570616
}
571617

@@ -649,6 +695,59 @@ func extractTableText(table *slides.Table) string {
649695
return strings.Join(rows, "\n")
650696
}
651697

698+
// extractSpeakerNotes extracts speaker notes text from a slide's notes page.
699+
func extractSpeakerNotes(slide *slides.Page) string {
700+
if slide.SlideProperties == nil {
701+
return ""
702+
}
703+
notesPage := slide.SlideProperties.NotesPage
704+
if notesPage == nil {
705+
return ""
706+
}
707+
if notesPage.NotesProperties == nil || notesPage.NotesProperties.SpeakerNotesObjectId == "" {
708+
return ""
709+
}
710+
711+
notesObjectID := notesPage.NotesProperties.SpeakerNotesObjectId
712+
for _, element := range notesPage.PageElements {
713+
if element.ObjectId == notesObjectID && element.Shape != nil {
714+
return extractShapeText(element.Shape)
715+
}
716+
}
717+
return ""
718+
}
719+
720+
// getSpeakerNotesObjectID returns the object ID of the speaker notes shape for a slide.
721+
func getSpeakerNotesObjectID(slide *slides.Page) (string, error) {
722+
if slide.SlideProperties == nil || slide.SlideProperties.NotesPage == nil {
723+
return "", fmt.Errorf("slide has no notes page")
724+
}
725+
notesPage := slide.SlideProperties.NotesPage
726+
if notesPage.NotesProperties == nil || notesPage.NotesProperties.SpeakerNotesObjectId == "" {
727+
return "", fmt.Errorf("slide has no speaker notes shape")
728+
}
729+
return notesPage.NotesProperties.SpeakerNotesObjectId, nil
730+
}
731+
732+
// findSlide resolves a slide from a presentation by --slide-id or --slide-number.
733+
func findSlide(presentation *slides.Presentation, slideIDFlag string, slideNumber int) (*slides.Page, error) {
734+
if slideIDFlag != "" && slideNumber > 0 {
735+
return nil, fmt.Errorf("specify only one of --slide-id or --slide-number, not both")
736+
}
737+
if slideIDFlag != "" {
738+
for _, s := range presentation.Slides {
739+
if s.ObjectId == slideIDFlag {
740+
return s, nil
741+
}
742+
}
743+
return nil, fmt.Errorf("slide with ID '%s' not found", slideIDFlag)
744+
}
745+
if slideNumber < 1 || slideNumber > len(presentation.Slides) {
746+
return nil, fmt.Errorf("slide number %d out of range (1-%d)", slideNumber, len(presentation.Slides))
747+
}
748+
return presentation.Slides[slideNumber-1], nil
749+
}
750+
652751
func runSlidesCreate(cmd *cobra.Command, args []string) error {
653752
p := printer.New(os.Stdout, GetFormat())
654753
ctx := context.Background()
@@ -1264,13 +1363,26 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error {
12641363
col, _ := cmd.Flags().GetInt("col")
12651364
text, _ := cmd.Flags().GetString("text")
12661365
insertionIndex, _ := cmd.Flags().GetInt("at")
1366+
notesMode, _ := cmd.Flags().GetBool("notes")
1367+
slideIDFlag, _ := cmd.Flags().GetString("slide-id")
1368+
slideNumber, _ := cmd.Flags().GetInt("slide-number")
12671369

12681370
// Validate mutually exclusive flags (fail fast before network calls)
1269-
if objectID != "" && tableID != "" {
1270-
return p.PrintError(fmt.Errorf("cannot specify both --object-id and --table-id"))
1371+
modeCount := 0
1372+
if objectID != "" {
1373+
modeCount++
1374+
}
1375+
if tableID != "" {
1376+
modeCount++
1377+
}
1378+
if notesMode {
1379+
modeCount++
12711380
}
1272-
if objectID == "" && tableID == "" {
1273-
return p.PrintError(fmt.Errorf("must specify either --object-id or --table-id"))
1381+
if modeCount > 1 {
1382+
return p.PrintError(fmt.Errorf("--object-id, --table-id, and --notes are mutually exclusive"))
1383+
}
1384+
if modeCount == 0 {
1385+
return p.PrintError(fmt.Errorf("must specify --object-id, --table-id, or --notes"))
12741386
}
12751387

12761388
// Validate table cell mode requires row and col
@@ -1283,6 +1395,11 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error {
12831395
}
12841396
}
12851397

1398+
// Validate notes mode requires slide targeting
1399+
if notesMode && slideIDFlag == "" && slideNumber == 0 {
1400+
return p.PrintError(fmt.Errorf("--notes requires --slide-id or --slide-number"))
1401+
}
1402+
12861403
// Now create the client after validation passes
12871404
ctx := context.Background()
12881405
factory, err := client.NewFactory(ctx)
@@ -1295,6 +1412,25 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error {
12951412
return p.PrintError(err)
12961413
}
12971414

1415+
// Resolve notes mode to an object ID
1416+
if notesMode {
1417+
presentation, err := svc.Presentations.Get(presentationID).Do()
1418+
if err != nil {
1419+
return p.PrintError(fmt.Errorf("failed to get presentation: %w", err))
1420+
}
1421+
1422+
slide, err := findSlide(presentation, slideIDFlag, slideNumber)
1423+
if err != nil {
1424+
return p.PrintError(err)
1425+
}
1426+
1427+
notesObjID, err := getSpeakerNotesObjectID(slide)
1428+
if err != nil {
1429+
return p.PrintError(fmt.Errorf("cannot target speaker notes: %w", err))
1430+
}
1431+
objectID = notesObjID
1432+
}
1433+
12981434
// Build the InsertText request
12991435
insertTextReq := &slides.InsertTextRequest{
13001436
Text: text,
@@ -1319,9 +1455,12 @@ func runSlidesAddText(cmd *cobra.Command, args []string) error {
13191455
result["row"] = row
13201456
result["col"] = col
13211457
} else {
1322-
// Shape/text box mode
1458+
// Shape/text box mode (including resolved notes mode)
13231459
insertTextReq.ObjectId = objectID
13241460
result["object_id"] = objectID
1461+
if notesMode {
1462+
result["target"] = "speaker_notes"
1463+
}
13251464
}
13261465

13271466
requests := []*slides.Request{
@@ -1453,8 +1592,29 @@ func runSlidesDeleteObject(cmd *cobra.Command, args []string) error {
14531592

14541593
func runSlidesDeleteText(cmd *cobra.Command, args []string) error {
14551594
p := printer.New(os.Stdout, GetFormat())
1456-
ctx := context.Background()
14571595

1596+
presentationID := args[0]
1597+
objectID, _ := cmd.Flags().GetString("object-id")
1598+
fromIndex, _ := cmd.Flags().GetInt("from")
1599+
toIndex, _ := cmd.Flags().GetInt("to")
1600+
notesMode, _ := cmd.Flags().GetBool("notes")
1601+
slideIDFlag, _ := cmd.Flags().GetString("slide-id")
1602+
slideNumber, _ := cmd.Flags().GetInt("slide-number")
1603+
1604+
// Validate: need either --object-id or --notes
1605+
if objectID == "" && !notesMode {
1606+
return p.PrintError(fmt.Errorf("must specify --object-id or --notes"))
1607+
}
1608+
if objectID != "" && notesMode {
1609+
return p.PrintError(fmt.Errorf("--object-id and --notes are mutually exclusive"))
1610+
}
1611+
1612+
// Validate notes mode requires slide targeting
1613+
if notesMode && slideIDFlag == "" && slideNumber == 0 {
1614+
return p.PrintError(fmt.Errorf("--notes requires --slide-id or --slide-number"))
1615+
}
1616+
1617+
ctx := context.Background()
14581618
factory, err := client.NewFactory(ctx)
14591619
if err != nil {
14601620
return p.PrintError(err)
@@ -1465,10 +1625,24 @@ func runSlidesDeleteText(cmd *cobra.Command, args []string) error {
14651625
return p.PrintError(err)
14661626
}
14671627

1468-
presentationID := args[0]
1469-
objectID, _ := cmd.Flags().GetString("object-id")
1470-
fromIndex, _ := cmd.Flags().GetInt("from")
1471-
toIndex, _ := cmd.Flags().GetInt("to")
1628+
// Resolve notes mode to an object ID
1629+
if notesMode {
1630+
presentation, err := svc.Presentations.Get(presentationID).Do()
1631+
if err != nil {
1632+
return p.PrintError(fmt.Errorf("failed to get presentation: %w", err))
1633+
}
1634+
1635+
slide, err := findSlide(presentation, slideIDFlag, slideNumber)
1636+
if err != nil {
1637+
return p.PrintError(err)
1638+
}
1639+
1640+
notesObjID, err := getSpeakerNotesObjectID(slide)
1641+
if err != nil {
1642+
return p.PrintError(fmt.Errorf("cannot target speaker notes: %w", err))
1643+
}
1644+
objectID = notesObjID
1645+
}
14721646

14731647
startIdx := int64(fromIndex)
14741648
textRange := &slides.Range{

0 commit comments

Comments
 (0)