Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- Chat: add repeatable `--attach` to `chat messages send` for sending local files with Google Chat messages. (#694) — thanks @omothm.
- Slides: add `slides insert-image` to place a positioned, sized local image on an existing slide. (#695) — thanks @Czaruno.

## 0.22.0 - 2026-06-07

Expand Down
1 change: 1 addition & 0 deletions docs/commands.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ Generated from `gog schema --json`.
- [`gog slides (slide) delete-slide <presentationId> <slideId>`](commands/gog-slides-delete-slide.md) - Delete a slide by object ID
- [`gog slides (slide) export (download,dl) <presentationId> [flags]`](commands/gog-slides-export.md) - Export a Google Slides deck (pdf|pptx)
- [`gog slides (slide) info (get,show) <presentationId>`](commands/gog-slides-info.md) - Get Google Slides presentation metadata
- [`gog slides (slide) insert-image --width=FLOAT-64 <presentationId> <slideId> <image> [flags]`](commands/gog-slides-insert-image.md) - Insert an image at a position and size on an existing slide
- [`gog slides (slide) insert-text <presentationId> <objectId> <text> [flags]`](commands/gog-slides-insert-text.md) - Insert text into an existing page element (shape or table) by objectId
- [`gog slides (slide) list-slides <presentationId>`](commands/gog-slides-list-slides.md) - List all slides with their object IDs
- [`gog slides (slide) raw <presentationId> [flags]`](commands/gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption)
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments.

Generated pages: 583.
Generated pages: 584.

## Top-level Commands

Expand Down Expand Up @@ -593,6 +593,7 @@ Generated pages: 583.
- [gog slides delete-slide](gog-slides-delete-slide.md) - Delete a slide by object ID
- [gog slides export](gog-slides-export.md) - Export a Google Slides deck (pdf|pptx)
- [gog slides info](gog-slides-info.md) - Get Google Slides presentation metadata
- [gog slides insert-image](gog-slides-insert-image.md) - Insert an image at a position and size on an existing slide
- [gog slides insert-text](gog-slides-insert-text.md) - Insert text into an existing page element (shape or table) by objectId
- [gog slides list-slides](gog-slides-list-slides.md) - List all slides with their object IDs
- [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption)
Expand Down
50 changes: 50 additions & 0 deletions docs/commands/gog-slides-insert-image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# `gog slides insert-image`

> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.

Insert an image at a position and size on an existing slide

## Usage

```bash
gog slides (slide) insert-image --width=FLOAT-64 <presentationId> <slideId> <image> [flags]
```

## Parent

- [gog slides](gog-slides.md)

## Flags

| Flag | Type | Default | Help |
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `--height` | `float64` | 0 | Image height, in --unit; omit to keep the image's aspect ratio |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--unit` | `string` | PT | Measurement unit for x/y/width/height (PT or EMU) |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--width` | `float64` | | Image width, in --unit |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
| `--x` | `float64` | 0 | Left position of the image, in --unit |
| `--y` | `float64` | 0 | Top position of the image, in --unit |

## See Also

- [gog slides](gog-slides.md)
- [Command index](README.md)
1 change: 1 addition & 0 deletions docs/commands/gog-slides.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ gog slides (slide) <command> [flags]
- [gog slides delete-slide](gog-slides-delete-slide.md) - Delete a slide by object ID
- [gog slides export](gog-slides-export.md) - Export a Google Slides deck (pdf|pptx)
- [gog slides info](gog-slides-info.md) - Get Google Slides presentation metadata
- [gog slides insert-image](gog-slides-insert-image.md) - Insert an image at a position and size on an existing slide
- [gog slides insert-text](gog-slides-insert-text.md) - Insert text into an existing page element (shape or table) by objectId
- [gog slides list-slides](gog-slides-list-slides.md) - List all slides with their object IDs
- [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption)
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/slides.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type SlidesCmd struct {
Thumbnail SlidesThumbnailCmd `cmd:"" name:"thumbnail" aliases:"thumb" help:"Get or download a rendered thumbnail for a slide"`
UpdateNotes SlidesUpdateNotesCmd `cmd:"" name:"update-notes" help:"Update speaker notes on an existing slide"`
ReplaceSlide SlidesReplaceSlideCmd `cmd:"" name:"replace-slide" help:"Replace the image on an existing slide in-place"`
InsertImage SlidesInsertImageCmd `cmd:"" name:"insert-image" help:"Insert an image at a position and size on an existing slide"`
InsertText SlidesInsertTextCmd `cmd:"" name:"insert-text" help:"Insert text into an existing page element (shape or table) by objectId"`
ReplaceText SlidesReplaceTextCmd `cmd:"" name:"replace-text" help:"Find-and-replace text across a presentation"`
Raw SlidesRawCmd `cmd:"" name:"raw" help:"Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption)"`
Expand Down
218 changes: 218 additions & 0 deletions internal/cmd/slides_insert_image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package cmd

import (
"context"
"fmt"
"image"
_ "image/gif" // register GIF decoder for aspect detection
_ "image/jpeg" // register JPEG decoder for aspect detection
_ "image/png" // register PNG decoder for aspect detection
"os"
"path/filepath"
"strings"
"time"

"google.golang.org/api/drive/v3"
"google.golang.org/api/slides/v1"

"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)

// SlidesInsertImageCmd inserts an image at an explicit position and size on an
// existing slide. Unlike add-slide (which lays a full-bleed image on a new
// slide), this places a sized element on a slide you already have, so callers
// can build native decks via the Slides API and still drop in a logo, chart,
// or badge at a precise location. It reuses the same private-image flow as
// add-slide: upload to Drive, grant a temporary read permission so the Slides
// image fetcher can read it, create the image, then delete the temp file.
type SlidesInsertImageCmd struct {
PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"`
SlideID string `arg:"" name:"slideId" help:"Slide object ID to place the image on"`
Image string `arg:"" name:"image" help:"Local image file (PNG/JPG/GIF)" type:"existingfile"`
X float64 `name:"x" default:"0" help:"Left position of the image, in --unit"`
Y float64 `name:"y" default:"0" help:"Top position of the image, in --unit"`
Width float64 `name:"width" required:"" help:"Image width, in --unit"`
Height float64 `name:"height" default:"0" help:"Image height, in --unit; omit to keep the image's aspect ratio"`
Unit string `name:"unit" enum:"PT,EMU" default:"PT" help:"Measurement unit for x/y/width/height (PT or EMU)"`
}

func (c *SlidesInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)

presentationID := strings.TrimSpace(c.PresentationID)
if presentationID == "" {
return usage("empty presentationId")
}
slideID := strings.TrimSpace(c.SlideID)
if slideID == "" {
return usage("empty slideId")
}
if c.Width <= 0 {
return usage("--width must be greater than 0")
}
if c.Height < 0 {
return usage("--height cannot be negative")
}

// Validate image format.
ext := strings.ToLower(filepath.Ext(c.Image))
var mimeType string
switch ext {
case extPNG:
mimeType = mimePNG
case imageExtJPG, imageExtJPEG:
mimeType = imageMimeJPEG
case imageExtGIF:
mimeType = imageMimeGIF
default:
return usagef("unsupported image format %q (use PNG, JPG, or GIF)", ext)
}

// Resolve height from the image's aspect ratio when not supplied.
height := c.Height
if height == 0 {
ar, err := imageAspectRatio(c.Image)
if err != nil {
return fmt.Errorf("determine image aspect ratio (pass --height to skip): %w", err)
}
height = c.Width * ar
}

if dryRunErr := dryRunExit(ctx, flags, "slides.insert-image", map[string]any{
"presentation_id": presentationID,
"slide_id": slideID,
"image": c.Image,
"mime_type": mimeType,
"x": c.X,
"y": c.Y,
"width": c.Width,
"height": height,
"unit": c.Unit,
}); dryRunErr != nil {
return dryRunErr
}

account, err := requireAccount(flags)
if err != nil {
return err
}

slidesSvc, err := newSlidesService(ctx, account)
if err != nil {
return err
}

// Confirm the target slide exists before creating the Drive service or
// uploading anything, so a bad slide id never touches Drive.
pres, err := slidesSvc.Presentations.Get(presentationID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("get presentation: %w", err)
}
if _, idx := findSlidesPageByID(pres, slideID); idx == -1 {
return fmt.Errorf("slide %q not found in presentation", slideID)
}

driveSvc, err := newDriveService(ctx, account)
if err != nil {
return err
}

// Upload the image to Drive as a temporary file.
imgFile, err := os.Open(c.Image)
if err != nil {
return fmt.Errorf("open image: %w", err)
}
defer imgFile.Close()

driveFile, err := driveSvc.Files.Create(&drive.File{
Name: filepath.Base(c.Image),
MimeType: mimeType,
}).Media(imgFile).Fields("id, webContentLink").Context(ctx).Do()
if err != nil {
return fmt.Errorf("upload image to Drive: %w", err)
}

// Clean up the temporary Drive file when done. Use a cancellation-immune
// context so the public temp file is still removed if the request context
// was canceled, and surface a loud warning if deletion fails (otherwise the
// uploaded image stays world-readable until someone removes it by hand).
defer func() {
if delErr := driveSvc.Files.Delete(driveFile.Id).Context(context.WithoutCancel(ctx)).Do(); delErr != nil {
u.Err().Linef("Warning: failed to delete temporary Drive image %s; it may remain publicly readable until removed: %v", driveFile.Id, delErr)
}
}()

// Make publicly readable so the Slides API can fetch it.
_, err = driveSvc.Permissions.Create(driveFile.Id, &drive.Permission{
Type: "anyone",
Role: "reader",
}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("set image permissions: %w", err)
}

imageURL := driveImageDownloadURL(driveFile.Id)
imageID := fmt.Sprintf("img_%d", time.Now().UnixNano())

err = batchUpdateSlidesImageRequests(ctx, slidesSvc, presentationID, &slides.BatchUpdatePresentationRequest{
Requests: []*slides.Request{
{
CreateImage: &slides.CreateImageRequest{
ObjectId: imageID,
Url: imageURL,
ElementProperties: &slides.PageElementProperties{
PageObjectId: slideID,
Size: &slides.Size{
Width: &slides.Dimension{Magnitude: c.Width, Unit: c.Unit},
Height: &slides.Dimension{Magnitude: height, Unit: c.Unit},
},
Transform: &slides.AffineTransform{
ScaleX: 1,
ScaleY: 1,
TranslateX: c.X,
TranslateY: c.Y,
Unit: c.Unit,
},
},
},
},
},
})
if err != nil {
return fmt.Errorf("insert image: %w", err)
}

link := fmt.Sprintf("https://docs.google.com/presentation/d/%s/edit", presentationID)

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"presentationId": presentationID,
"slideObjectId": slideID,
"imageObjectId": imageID,
"link": link,
})
}

u.Out().Linef("image\t%s", imageID)
u.Out().Linef("link\t%s", link)
return nil
}

// imageAspectRatio returns height/width for the given image file.
func imageAspectRatio(path string) (float64, error) {
f, err := os.Open(path) //nolint:gosec // user-provided local image path is the command input.
if err != nil {
return 0, fmt.Errorf("open image: %w", err)
}
defer f.Close()

cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, fmt.Errorf("decode image config: %w", err)
}
if cfg.Width <= 0 {
return 0, fmt.Errorf("image has zero width")
}
return float64(cfg.Height) / float64(cfg.Width), nil
}
Loading
Loading