Skip to content

Commit 83f51c6

Browse files
authored
feat(docs): add non-destructive image anchors (#843)
1 parent c02fdf7 commit 83f51c6

6 files changed

Lines changed: 172 additions & 34 deletions

File tree

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 non-destructive `insert-image --before` and `--after` anchors while clarifying that `--at` replaces its placeholder. (#839) — thanks @sebsnyk.
78
- Slides: allow `insert-image` and `replace-slide` to use public HTTPS image URLs without temporary Drive sharing. (#825) — thanks @sebsnyk.
89
- Slides: add structured element geometry, styled text runs, table-cell content, image source URLs, native presentation metadata, and read-only text location with exact UTF-16 ranges. (#822) — thanks @sebsnyk.
910
- Slides: add range-scoped styling, links, bullets, and object-scoped replacement; `replace-text` now requires explicit `--object`, `--page`, or `--all` scope instead of silently changing the whole deck. (#823, #835) — thanks @sebsnyk.

docs/commands/gog-docs-insert-image.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ gog docs (doc) insert-image <docId> [flags]
2020
| --- | --- | --- | --- |
2121
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
2222
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
23-
| `--at` | `string` | end | Placeholder text to replace, or 'end' to append |
23+
| `--after` | `*string` | | Insert after the first literal text match without deleting it |
24+
| `--at` | `*string` | | Placeholder text to delete and replace, or 'end' to append |
25+
| `--before` | `*string` | | Insert before the first literal text match without deleting it |
2426
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
2527
| `--color` | `string` | auto | Color output: auto\|always\|never |
2628
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |

docs/docs-editing.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,9 @@ gog docs insert-image <docId> --url https://example.com/chart.png --at end
178178

179179
Use `--file` for local PNG, JPEG, or GIF input. Local files are uploaded to
180180
Drive and may require link sharing; `--url` leaves Drive permissions unchanged.
181-
Both modes accept `--tab`, `--width`, and `--height`.
181+
Both modes accept `--tab`, `--width`, and `--height`. Omit anchor flags or use
182+
`--at end` to append. `--at <text>` deletes and replaces the first literal text
183+
match; use `--before <text>` or `--after <text>` to preserve the anchor text.
182184

183185
Command page:
184186

internal/cmd/docs_insert_image.go

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ type DocsInsertImageCmd struct {
2424
DocID string `arg:"" name:"docId" help:"Doc ID"`
2525
File string `name:"file" help:"Local PNG, JPEG, or GIF image to upload and insert" type:"existingfile"`
2626
URL string `name:"url" help:"Public HTTPS image URL to insert directly"`
27-
At string `name:"at" help:"Placeholder text to replace, or 'end' to append" default:"end"`
27+
At *string `name:"at" help:"Placeholder text to delete and replace, or 'end' to append"`
28+
Before *string `name:"before" help:"Insert before the first literal text match without deleting it"`
29+
After *string `name:"after" help:"Insert after the first literal text match without deleting it"`
2830
Width float64 `name:"width" help:"Image width in points; default 468pt" default:"468"`
2931
Height float64 `name:"height" help:"Image height in points (optional; width-only preserves aspect ratio)"`
3032
Parent string `name:"parent" help:"Drive folder ID for the uploaded image"`
@@ -53,6 +55,19 @@ type docsInsertImageSource struct {
5355
imageURL string
5456
}
5557

58+
type docsImageAnchorMode string
59+
60+
const (
61+
docsImageAnchorReplace docsImageAnchorMode = "at"
62+
docsImageAnchorBefore docsImageAnchorMode = "before"
63+
docsImageAnchorAfter docsImageAnchorMode = "after"
64+
)
65+
66+
type docsImageTarget struct {
67+
anchor string
68+
mode docsImageAnchorMode
69+
}
70+
5671
func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error {
5772
docID := strings.TrimSpace(c.DocID)
5873
if docID == "" {
@@ -65,16 +80,16 @@ func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error {
6580
if err != nil {
6681
return err
6782
}
68-
at := strings.TrimSpace(c.At)
69-
if at == "" {
70-
return usage("empty --at")
83+
target, err := c.resolveTarget()
84+
if err != nil {
85+
return err
7186
}
7287
dryRunPayload := map[string]any{
73-
"documentId": docID,
74-
"at": at,
75-
"width": c.Width,
76-
"height": c.Height,
77-
"tab": c.Tab,
88+
"documentId": docID,
89+
"width": c.Width,
90+
"height": c.Height,
91+
"tab": c.Tab,
92+
string(target.mode): target.anchor,
7893
}
7994
if source.imageURL != "" {
8095
dryRunPayload["url"] = source.imageURL
@@ -105,20 +120,48 @@ func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error {
105120

106121
var result docsInsertImageResult
107122
if source.imageURL != "" {
108-
result, err = c.runURL(ctx, docsSvc, docID, source.imageURL, at)
123+
result, err = c.runURL(ctx, docsSvc, docID, source.imageURL, target)
109124
} else {
110125
driveSvc, driveErr := driveService(ctx, account)
111126
if driveErr != nil {
112127
return driveErr
113128
}
114-
result, err = c.runFile(ctx, docsSvc, driveSvc, docID, source.localPath, source.name, source.mimeType, at)
129+
result, err = c.runFile(ctx, docsSvc, driveSvc, docID, source.localPath, source.name, source.mimeType, target)
115130
}
116131
if err != nil {
117132
return err
118133
}
119134
return writeDocsInsertImageResult(ctx, result)
120135
}
121136

137+
func (c *DocsInsertImageCmd) resolveTarget() (docsImageTarget, error) {
138+
set := 0
139+
for _, value := range []*string{c.At, c.Before, c.After} {
140+
if value != nil {
141+
set++
142+
}
143+
}
144+
if set > 1 {
145+
return docsImageTarget{}, usage("--at, --before, and --after are mutually exclusive")
146+
}
147+
148+
target := docsImageTarget{anchor: docsAtIndexEnd, mode: docsImageAnchorReplace}
149+
switch {
150+
case c.At != nil:
151+
target.anchor = strings.TrimSpace(*c.At)
152+
case c.Before != nil:
153+
target.anchor = strings.TrimSpace(*c.Before)
154+
target.mode = docsImageAnchorBefore
155+
case c.After != nil:
156+
target.anchor = strings.TrimSpace(*c.After)
157+
target.mode = docsImageAnchorAfter
158+
}
159+
if target.anchor == "" {
160+
return docsImageTarget{}, usage(fmt.Sprintf("empty --%s", target.mode))
161+
}
162+
return target, nil
163+
}
164+
122165
func (c *DocsInsertImageCmd) resolveSource() (docsInsertImageSource, error) {
123166
localFile := strings.TrimSpace(c.File)
124167
imageURL := strings.TrimSpace(c.URL)
@@ -198,12 +241,12 @@ func writeDocsInsertImageResult(ctx context.Context, result docsInsertImageResul
198241
return nil
199242
}
200243

201-
func (c *DocsInsertImageCmd) runURL(ctx context.Context, docsSvc *docs.Service, docID, imageURL, at string) (docsInsertImageResult, error) {
244+
func (c *DocsInsertImageCmd) runURL(ctx context.Context, docsSvc *docs.Service, docID, imageURL string, target docsImageTarget) (docsInsertImageResult, error) {
202245
result := docsInsertImageResult{sourceURL: imageURL}
203-
return c.insertImageURL(ctx, docsSvc, docID, imageURL, at, result)
246+
return c.insertImageURL(ctx, docsSvc, docID, imageURL, target, result)
204247
}
205248

206-
func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service, driveSvc *drive.Service, docID, localPath, name, mimeType, at string) (result docsInsertImageResult, err error) {
249+
func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service, driveSvc *drive.Service, docID, localPath, name, mimeType string, target docsImageTarget) (result docsInsertImageResult, err error) {
207250
uploaded, err := uploadDocsInlineImage(ctx, driveSvc, localPath, name, mimeType, strings.TrimSpace(c.Parent))
208251
if err != nil {
209252
return result, err
@@ -218,7 +261,7 @@ func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service,
218261
Do()
219262
if err != nil {
220263
if strings.EqualFold(c.OnRestricted, "link") && isDrivePublicSharingRestricted(err) {
221-
return c.insertRestrictedImageFallback(ctx, docsSvc, docID, uploaded, at, result)
264+
return c.insertRestrictedImageFallback(ctx, docsSvc, docID, uploaded, target, result)
222265
}
223266
return result, fmt.Errorf("share uploaded image publicly: %w", err)
224267
}
@@ -243,11 +286,11 @@ func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service,
243286
}()
244287

245288
imageURL := driveImageDownloadURL(uploaded.Id)
246-
return c.insertImageURL(ctx, docsSvc, docID, imageURL, at, result)
289+
return c.insertImageURL(ctx, docsSvc, docID, imageURL, target, result)
247290
}
248291

249-
func (c *DocsInsertImageCmd) insertImageURL(ctx context.Context, docsSvc *docs.Service, docID, imageURL, at string, result docsInsertImageResult) (docsInsertImageResult, error) {
250-
reqs, index, tabID, err := c.buildInsertRequests(ctx, docsSvc, docID, at, imageURL)
292+
func (c *DocsInsertImageCmd) insertImageURL(ctx context.Context, docsSvc *docs.Service, docID, imageURL string, target docsImageTarget, result docsInsertImageResult) (docsInsertImageResult, error) {
293+
reqs, index, tabID, err := c.buildInsertRequests(ctx, docsSvc, docID, target, imageURL)
251294
if err != nil {
252295
return result, err
253296
}
@@ -261,12 +304,12 @@ func (c *DocsInsertImageCmd) insertImageURL(ctx context.Context, docsSvc *docs.S
261304
return result, nil
262305
}
263306

264-
func (c *DocsInsertImageCmd) insertRestrictedImageFallback(ctx context.Context, docsSvc *docs.Service, docID string, uploaded *drive.File, at string, result docsInsertImageResult) (docsInsertImageResult, error) {
307+
func (c *DocsInsertImageCmd) insertRestrictedImageFallback(ctx context.Context, docsSvc *docs.Service, docID string, uploaded *drive.File, target docsImageTarget, result docsInsertImageResult) (docsInsertImageResult, error) {
265308
link := uploaded.WebViewLink
266309
if link == "" {
267310
link = bestEffortWebURL("drive", uploaded.Id)
268311
}
269-
reqs, index, tabID, err := c.buildLinkFallbackRequests(ctx, docsSvc, docID, at, link)
312+
reqs, index, tabID, err := c.buildLinkFallbackRequests(ctx, docsSvc, docID, target, link)
270313
if err != nil {
271314
return result, err
272315
}
@@ -305,8 +348,8 @@ func uploadDocsInlineImage(ctx context.Context, svc *drive.Service, localPath, n
305348
return created, nil
306349
}
307350

308-
func (c *DocsInsertImageCmd) buildInsertRequests(ctx context.Context, svc *docs.Service, docID, at, imageURL string) ([]*docs.Request, int64, string, error) {
309-
index, placeholder, tabID, err := c.resolveImageTarget(ctx, svc, docID, at)
351+
func (c *DocsInsertImageCmd) buildInsertRequests(ctx context.Context, svc *docs.Service, docID string, target docsImageTarget, imageURL string) ([]*docs.Request, int64, string, error) {
352+
index, placeholder, tabID, err := c.resolveImageTarget(ctx, svc, docID, target)
310353
if err != nil {
311354
return nil, 0, "", err
312355
}
@@ -331,8 +374,8 @@ func (c *DocsInsertImageCmd) buildInsertRequests(ctx context.Context, svc *docs.
331374
return reqs, index, tabID, nil
332375
}
333376

334-
func (c *DocsInsertImageCmd) buildLinkFallbackRequests(ctx context.Context, svc *docs.Service, docID, at, link string) ([]*docs.Request, int64, string, error) {
335-
index, placeholder, tabID, err := c.resolveImageTarget(ctx, svc, docID, at)
377+
func (c *DocsInsertImageCmd) buildLinkFallbackRequests(ctx context.Context, svc *docs.Service, docID string, target docsImageTarget, link string) ([]*docs.Request, int64, string, error) {
378+
index, placeholder, tabID, err := c.resolveImageTarget(ctx, svc, docID, target)
336379
if err != nil {
337380
return nil, 0, "", err
338381
}
@@ -349,8 +392,8 @@ func (c *DocsInsertImageCmd) buildLinkFallbackRequests(ctx context.Context, svc
349392
return reqs, index, tabID, nil
350393
}
351394

352-
func (c *DocsInsertImageCmd) resolveImageTarget(ctx context.Context, svc *docs.Service, docID, at string) (int64, *docsedit.TextRange, string, error) {
353-
if strings.EqualFold(at, docsAtIndexEnd) {
395+
func (c *DocsInsertImageCmd) resolveImageTarget(ctx context.Context, svc *docs.Service, docID string, target docsImageTarget) (int64, *docsedit.TextRange, string, error) {
396+
if target.mode == docsImageAnchorReplace && strings.EqualFold(target.anchor, docsAtIndexEnd) {
354397
endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab)
355398
if err != nil {
356399
return 0, nil, "", err
@@ -361,15 +404,23 @@ func (c *DocsInsertImageCmd) resolveImageTarget(ctx context.Context, svc *docs.S
361404
if err != nil {
362405
return 0, nil, "", err
363406
}
364-
matches := docsedit.FindTextRanges(loaded.target, at, docsedit.SearchOptions{
407+
matches := docsedit.FindTextRanges(loaded.target, target.anchor, docsedit.SearchOptions{
365408
MatchCase: true,
366409
PreserveHTMLEntities: true,
367410
RequireTextSegment: true,
368411
})
369412
if len(matches) == 0 {
370-
return 0, nil, "", fmt.Errorf("placeholder not found: %q", at)
413+
return 0, nil, "", fmt.Errorf("anchor not found: %q", target.anchor)
414+
}
415+
match := matches[0]
416+
switch target.mode {
417+
case docsImageAnchorBefore:
418+
return match.StartIndex, nil, loaded.tabID, nil
419+
case docsImageAnchorAfter:
420+
return match.EndIndex, nil, loaded.tabID, nil
421+
default:
422+
return match.StartIndex, &match, loaded.tabID, nil
371423
}
372-
return matches[0].StartIndex, &matches[0], loaded.tabID, nil
373424
}
374425

375426
func isDocsInsertImageMime(mimeType string) bool {

internal/cmd/docs_insert_image_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,41 @@ func TestDocsInsertImageResolveSourceURL(t *testing.T) {
5050
}
5151
}
5252

53+
func TestDocsInsertImageResolveTarget(t *testing.T) {
54+
value := func(s string) *string { return &s }
55+
tests := []struct {
56+
name string
57+
cmd DocsInsertImageCmd
58+
wantAnchor string
59+
wantMode docsImageAnchorMode
60+
wantErr string
61+
}{
62+
{name: "default end", cmd: DocsInsertImageCmd{}, wantAnchor: "end", wantMode: docsImageAnchorReplace},
63+
{name: "replace", cmd: DocsInsertImageCmd{At: value(" marker ")}, wantAnchor: "marker", wantMode: docsImageAnchorReplace},
64+
{name: "before", cmd: DocsInsertImageCmd{Before: value(" marker ")}, wantAnchor: "marker", wantMode: docsImageAnchorBefore},
65+
{name: "after", cmd: DocsInsertImageCmd{After: value(" marker ")}, wantAnchor: "marker", wantMode: docsImageAnchorAfter},
66+
{name: "empty", cmd: DocsInsertImageCmd{Before: value(" ")}, wantErr: "empty --before"},
67+
{name: "conflict", cmd: DocsInsertImageCmd{At: value("a"), After: value("b")}, wantErr: "mutually exclusive"},
68+
}
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
target, err := tt.cmd.resolveTarget()
72+
if tt.wantErr != "" {
73+
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
74+
t.Fatalf("resolveTarget() error = %v, want %q", err, tt.wantErr)
75+
}
76+
return
77+
}
78+
if err != nil {
79+
t.Fatalf("resolveTarget(): %v", err)
80+
}
81+
if target.anchor != tt.wantAnchor || target.mode != tt.wantMode {
82+
t.Fatalf("resolveTarget() = %#v", target)
83+
}
84+
})
85+
}
86+
}
87+
5388
func TestDocsInsertImageURLRunSkipsDrive(t *testing.T) {
5489
t.Parallel()
5590

@@ -136,12 +171,13 @@ func TestDocsInsertImageURLDryRunSkipsServices(t *testing.T) {
136171
Op string `json:"op"`
137172
Request struct {
138173
URL string `json:"url"`
174+
At string `json:"at"`
139175
} `json:"request"`
140176
}
141177
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
142178
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
143179
}
144-
if payload.Op != "docs.insert-image" || payload.Request.URL != "https://example.com/image.png" {
180+
if payload.Op != "docs.insert-image" || payload.Request.URL != "https://example.com/image.png" || payload.Request.At != "end" {
145181
t.Fatalf("unexpected dry-run output: %#v", payload)
146182
}
147183
}

internal/cmd/docs_remaining_features_test.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ func TestDocsSmartChipCommandsBuildRequests(t *testing.T) {
332332
}
333333

334334
func TestDocsInsertImageBuildsPlaceholderReplacement(t *testing.T) {
335-
cmd := &DocsInsertImageCmd{At: "IMG_HERE", Width: 320}
335+
cmd := &DocsInsertImageCmd{Width: 320}
336336
docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
337337
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/documents/doc1") {
338338
http.NotFound(w, r)
@@ -343,7 +343,8 @@ func TestDocsInsertImageBuildsPlaceholderReplacement(t *testing.T) {
343343
}))
344344
defer cleanup()
345345

346-
reqs, index, tabID, err := cmd.buildInsertRequests(context.Background(), docSvc, "doc1", "IMG_HERE", "https://example.com/i.png")
346+
target := docsImageTarget{anchor: "IMG_HERE", mode: docsImageAnchorReplace}
347+
reqs, index, tabID, err := cmd.buildInsertRequests(context.Background(), docSvc, "doc1", target, "https://example.com/i.png")
347348
if err != nil {
348349
t.Fatalf("buildInsertRequests: %v", err)
349350
}
@@ -357,3 +358,48 @@ func TestDocsInsertImageBuildsPlaceholderReplacement(t *testing.T) {
357358
t.Fatalf("unexpected image request: %#v", reqs[1].InsertInlineImage)
358359
}
359360
}
361+
362+
func TestDocsInsertImageBuildsNonDestructiveAnchorInsertions(t *testing.T) {
363+
cmd := &DocsInsertImageCmd{Width: 320}
364+
docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
365+
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/v1/documents/doc1") {
366+
http.NotFound(w, r)
367+
return
368+
}
369+
w.Header().Set("Content-Type", "application/json")
370+
_ = json.NewEncoder(w).Encode(docBodyWithText("before IMG_HERE after\n"))
371+
}))
372+
defer cleanup()
373+
374+
for _, tc := range []struct {
375+
name string
376+
mode docsImageAnchorMode
377+
index int64
378+
}{
379+
{name: "before", mode: docsImageAnchorBefore, index: 8},
380+
{name: "after", mode: docsImageAnchorAfter, index: 16},
381+
} {
382+
t.Run(tc.name, func(t *testing.T) {
383+
target := docsImageTarget{anchor: "IMG_HERE", mode: tc.mode}
384+
reqs, index, _, err := cmd.buildInsertRequests(context.Background(), docSvc, "doc1", target, "https://example.com/i.png")
385+
if err != nil {
386+
t.Fatalf("buildInsertRequests: %v", err)
387+
}
388+
if index != tc.index || len(reqs) != 1 || reqs[0].InsertInlineImage == nil {
389+
t.Fatalf("index=%d requests=%#v", index, reqs)
390+
}
391+
if reqs[0].InsertInlineImage.Location.Index != tc.index {
392+
t.Fatalf("image location = %#v", reqs[0].InsertInlineImage.Location)
393+
}
394+
})
395+
}
396+
397+
target := docsImageTarget{anchor: "IMG_HERE", mode: docsImageAnchorAfter}
398+
reqs, index, _, err := cmd.buildLinkFallbackRequests(context.Background(), docSvc, "doc1", target, "https://example.com/file")
399+
if err != nil {
400+
t.Fatalf("buildLinkFallbackRequests: %v", err)
401+
}
402+
if index != 16 || len(reqs) != 1 || reqs[0].InsertText == nil || reqs[0].InsertText.Location.Index != 16 {
403+
t.Fatalf("index=%d requests=%#v", index, reqs)
404+
}
405+
}

0 commit comments

Comments
 (0)