Skip to content

Commit 87029ed

Browse files
committed
feat(docs): add layout-preserving image replacement
1 parent ab9a331 commit 87029ed

11 files changed

Lines changed: 681 additions & 19 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Docs: add `replace-image` with exact object-ID, alt-text, single-image, tab, public-URL, and local-file targeting while preserving the original image position and bounds. (#853) — thanks @sebsnyk.
78
- Docs: add `insert --markdown` to convert markdown to Google Docs formatting and place the converted block at a position resolved by `--index`/`--at`/`--occurrence`, giving `insert` parity with the existing `update --markdown` and `write --markdown` paths. (#851, #854) — thanks @sebsnyk.
89

910
## 0.29.0 - 2026-06-19

docs/commands.generated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ Generated from `gog schema --json`.
270270
- [`gog docs (doc) paragraphs list (ls) <docId> [flags]`](commands/gog-docs-paragraphs-list.md) - List paragraphs
271271
- [`gog docs (doc) raw <docId> [flags]`](commands/gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption)
272272
- [`gog docs (doc) rename-tab <docId> [flags]`](commands/gog-docs-rename-tab.md) - Rename a tab in a Google Doc
273+
- [`gog docs (doc) replace-image <docId> [flags]`](commands/gog-docs-replace-image.md) - Replace an existing image without changing its position or bounds
273274
- [`gog docs (doc) sed <docId> [<expression>] [flags]`](commands/gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g)
274275
- [`gog docs (doc) structure (struct) <docId> [flags]`](commands/gog-docs-structure.md) - Show document structure with numbered paragraphs
275276
- [`gog docs (doc) table-column <command>`](commands/gog-docs-table-column.md) - Insert or delete native table columns

docs/commands/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
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.
44

5-
Generated pages: 679.
5+
Generated pages: 680.
66

77
## Top-level Commands
88

@@ -321,6 +321,7 @@ Generated pages: 679.
321321
- [gog docs paragraphs list](gog-docs-paragraphs-list.md) - List paragraphs
322322
- [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption)
323323
- [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc
324+
- [gog docs replace-image](gog-docs-replace-image.md) - Replace an existing image without changing its position or bounds
324325
- [gog docs sed](gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g)
325326
- [gog docs structure](gog-docs-structure.md) - Show document structure with numbered paragraphs
326327
- [gog docs table-column](gog-docs-table-column.md) - Insert or delete native table columns
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# `gog docs replace-image`
2+
3+
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
4+
5+
Replace an existing image without changing its position or bounds
6+
7+
## Usage
8+
9+
```bash
10+
gog docs (doc) replace-image <docId> [flags]
11+
```
12+
13+
## Parent
14+
15+
- [gog docs](gog-docs.md)
16+
17+
## Flags
18+
19+
| Flag | Type | Default | Help |
20+
| --- | --- | --- | --- |
21+
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
22+
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
23+
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
24+
| `--color` | `string` | auto | Color output: auto\|always\|never |
25+
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
26+
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
27+
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
28+
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
29+
| `--file` | `string` | | Local PNG, JPEG, or GIF image to upload and use |
30+
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
31+
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
32+
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
33+
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
34+
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
35+
| `--match-alt` | `string` | | Select the image whose alt text contains this value (case-insensitive) |
36+
| `--name` | `string` | | Override the uploaded Drive filename |
37+
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
38+
| `--object-id` | `string` | | Exact image object ID from docs images list |
39+
| `--parent` | `string` | | Drive folder ID for an uploaded local image |
40+
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
41+
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
42+
| `--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. |
43+
| `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) |
44+
| `--url` | `string` | | Public HTTPS image URL to use directly |
45+
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
46+
| `--version` | `kong.VersionFlag` | | Print version and exit |
47+
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
48+
49+
## See Also
50+
51+
- [gog docs](gog-docs.md)
52+
- [Command index](README.md)

docs/commands/gog-docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ gog docs (doc) <command> [flags]
4747
- [gog docs paragraphs](gog-docs-paragraphs.md) - List document paragraphs
4848
- [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption)
4949
- [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc
50+
- [gog docs replace-image](gog-docs-replace-image.md) - Replace an existing image without changing its position or bounds
5051
- [gog docs sed](gog-docs-sed.md) - Regex find/replace (sed-style: s/pattern/replacement/g)
5152
- [gog docs structure](gog-docs-structure.md) - Show document structure with numbered paragraphs
5253
- [gog docs table-column](gog-docs-table-column.md) - Insert or delete native table columns

docs/docs-editing.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,21 @@ Both modes accept `--tab`, `--width`, and `--height`. Omit anchor flags or use
191191
`--at end` to append. `--at <text>` deletes and replaces the first literal text
192192
match; use `--before <text>` or `--after <text>` to preserve the anchor text.
193193

194-
Command page:
194+
Replace an existing image without changing its position or rendered bounds:
195+
196+
```bash
197+
gog docs replace-image <docId> --object-id <imageObjectId> --url https://example.com/chart.png
198+
gog docs replace-image <docId> --match-alt "Quarterly chart" --file chart.png
199+
```
200+
201+
Get image object IDs and alt text from `docs images list`. When the selected tab
202+
contains exactly one image, omit both selector flags. Local replacement files
203+
use the same temporary Drive sharing and permission cleanup as `insert-image`.
204+
205+
Command pages:
195206

196207
- [`gog docs insert-image`](commands/gog-docs-insert-image.md)
208+
- [`gog docs replace-image`](commands/gog-docs-replace-image.md)
197209

198210
## Page Breaks
199211

internal/cmd/docs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type DocsCmd struct {
3838
TableUnmerge DocsTableUnmergeCmd `cmd:"" name:"table-unmerge" aliases:"table-split" help:"Unmerge the region containing a native table cell"`
3939
TableColumnWidth DocsTableColumnWidthCmd `cmd:"" name:"table-column-width" aliases:"table-width,column-width" help:"Set or reset native table column widths"`
4040
InsertImage DocsInsertImageCmd `cmd:"" name:"insert-image" help:"Insert a public image URL or upload a local image into a Google Doc"`
41+
ReplaceImage DocsReplaceImageCmd `cmd:"" name:"replace-image" help:"Replace an existing image without changing its position or bounds"`
4142
InsertPerson DocsInsertPersonCmd `cmd:"" name:"insert-person" help:"Insert a native person smart chip"`
4243
InsertFileChip DocsInsertFileChipCmd `cmd:"" name:"insert-file-chip" aliases:"insert-rich-link" help:"Insert a native Drive file smart chip"`
4344
InsertDateChip DocsInsertDateChipCmd `cmd:"" name:"insert-date-chip" help:"Insert a native date smart chip"`

internal/cmd/docs_enumerators.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,19 @@ func loadDocsEnumeratorDocument(
271271
if err != nil {
272272
return nil, "", err
273273
}
274+
return loadDocsEnumeratorDocumentWithService(ctx, svc, id, tabQuery)
275+
}
276+
277+
func loadDocsEnumeratorDocumentWithService(
278+
ctx context.Context,
279+
svc *docs.Service,
280+
docID string,
281+
tabQuery string,
282+
) (*docs.Document, string, error) {
283+
id := normalizeGoogleID(strings.TrimSpace(docID))
284+
if id == "" {
285+
return nil, "", usage("empty docId")
286+
}
274287
tabQuery = strings.TrimSpace(tabQuery)
275288
call := svc.Documents.Get(id).Context(ctx)
276289
if tabQuery != "" {

internal/cmd/docs_insert_image.go

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -254,11 +254,7 @@ func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service,
254254
result.uploadedFileID = uploaded.Id
255255
result.uploadedFileName = uploaded.Name
256256

257-
perm, err := driveSvc.Permissions.Create(uploaded.Id, &drive.Permission{Type: "anyone", Role: drivePermRoleReader}).
258-
SupportsAllDrives(true).
259-
Fields("id,type,role").
260-
Context(ctx).
261-
Do()
257+
perm, err := shareDocsImagePublicly(ctx, driveSvc, uploaded.Id)
262258
if err != nil {
263259
if strings.EqualFold(c.OnRestricted, "link") && isDrivePublicSharingRestricted(err) {
264260
return c.insertRestrictedImageFallback(ctx, docsSvc, docID, uploaded, target, result)
@@ -271,18 +267,7 @@ func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service,
271267
if perm.Id == "" {
272268
return
273269
}
274-
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
275-
defer cancel()
276-
revokeErr := driveSvc.Permissions.Delete(uploaded.Id, perm.Id).SupportsAllDrives(true).Context(cleanupCtx).Do()
277-
if revokeErr == nil {
278-
result.revoked = true
279-
return
280-
}
281-
if err != nil {
282-
err = fmt.Errorf("%w; additionally failed to revoke temporary public permission %s on %s: %w", err, perm.Id, uploaded.Id, revokeErr)
283-
return
284-
}
285-
err = fmt.Errorf("revoke temporary public permission %s on %s: %w", perm.Id, uploaded.Id, revokeErr)
270+
result.revoked, err = finishDocsImagePublicShare(ctx, driveSvc, uploaded.Id, perm.Id, err)
286271
}()
287272

288273
imageURL := driveImageDownloadURL(uploaded.Id)
@@ -348,6 +333,36 @@ func uploadDocsInlineImage(ctx context.Context, svc *drive.Service, localPath, n
348333
return created, nil
349334
}
350335

336+
func shareDocsImagePublicly(ctx context.Context, svc *drive.Service, fileID string) (*drive.Permission, error) {
337+
return svc.Permissions.Create(fileID, &drive.Permission{Type: "anyone", Role: drivePermRoleReader}).
338+
SupportsAllDrives(true).
339+
Fields("id,type,role").
340+
Context(ctx).
341+
Do()
342+
}
343+
344+
func finishDocsImagePublicShare(
345+
ctx context.Context,
346+
svc *drive.Service,
347+
fileID string,
348+
permissionID string,
349+
operationErr error,
350+
) (bool, error) {
351+
if permissionID == "" {
352+
return false, operationErr
353+
}
354+
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
355+
defer cancel()
356+
revokeErr := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(cleanupCtx).Do()
357+
if revokeErr == nil {
358+
return true, operationErr
359+
}
360+
if operationErr != nil {
361+
return false, fmt.Errorf("%w; additionally failed to revoke temporary public permission %s on %s: %w", operationErr, permissionID, fileID, revokeErr)
362+
}
363+
return false, fmt.Errorf("revoke temporary public permission %s on %s: %w", permissionID, fileID, revokeErr)
364+
}
365+
351366
func (c *DocsInsertImageCmd) buildInsertRequests(ctx context.Context, svc *docs.Service, docID string, target docsImageTarget, imageURL string) ([]*docs.Request, int64, string, error) {
352367
index, placeholder, tabID, err := c.resolveImageTarget(ctx, svc, docID, target)
353368
if err != nil {

0 commit comments

Comments
 (0)