diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7115a25..6a26f69c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Docs: add `--tab` and `--all-tabs` to `docs raw` for inspecting specific or complete multi-tab document content. (#697) — thanks @sebsnyk. - Docs: add tab-aware table, image, heading, and paragraph enumerators with structured and plain output. (#719) — thanks @sebsnyk. - Docs: style locally rendered fenced Markdown blocks with Roboto Mono, dark-green text, and existing paragraph shading. (#676, #724) — thanks @TurboTheTurtle. +- Docs: add `docs insert-image --url` for inserting public HTTPS images directly without Drive upload or temporary public sharing. (#675) — thanks @sebsnyk. ### Fixed diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 125cf482b..0490d6022 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -249,7 +249,7 @@ Generated from `gog schema --json`. - [`gog docs (doc) insert [] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position - [`gog docs (doc) insert-date-chip --date=STRING [flags]`](commands/gog-docs-insert-date-chip.md) - Insert a native date smart chip - [`gog docs (doc) insert-file-chip (insert-rich-link) --file-id=STRING [flags]`](commands/gog-docs-insert-file-chip.md) - Insert a native Drive file smart chip - - [`gog docs (doc) insert-image --file=STRING [flags]`](commands/gog-docs-insert-image.md) - Upload a local image and insert it into a Google Doc + - [`gog docs (doc) insert-image [flags]`](commands/gog-docs-insert-image.md) - Insert a public image URL or upload a local image into a Google Doc - [`gog docs (doc) insert-page-break (page-break,pb) [flags]`](commands/gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) - [`gog docs (doc) insert-person --email=STRING [flags]`](commands/gog-docs-insert-person.md) - Insert a native person smart chip - [`gog docs (doc) insert-table --rows=INT --cols=INT [flags]`](commands/gog-docs-insert-table.md) - Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json diff --git a/docs/commands/README.md b/docs/commands/README.md index 18c480bde..593b2418d 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -301,7 +301,7 @@ Generated pages: 615. - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs insert-date-chip](gog-docs-insert-date-chip.md) - Insert a native date smart chip - [gog docs insert-file-chip](gog-docs-insert-file-chip.md) - Insert a native Drive file smart chip - - [gog docs insert-image](gog-docs-insert-image.md) - Upload a local image and insert it into a Google Doc + - [gog docs insert-image](gog-docs-insert-image.md) - Insert a public image URL or upload a local image into a Google Doc - [gog docs insert-page-break](gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) - [gog docs insert-person](gog-docs-insert-person.md) - Insert a native person smart chip - [gog docs insert-table](gog-docs-insert-table.md) - Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json diff --git a/docs/commands/gog-docs-insert-image.md b/docs/commands/gog-docs-insert-image.md index 8aa119237..bb397581c 100644 --- a/docs/commands/gog-docs-insert-image.md +++ b/docs/commands/gog-docs-insert-image.md @@ -2,12 +2,12 @@ > Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. -Upload a local image and insert it into a Google Doc +Insert a public image URL or upload a local image into a Google Doc ## Usage ```bash -gog docs (doc) insert-image --file=STRING [flags] +gog docs (doc) insert-image [flags] ``` ## Parent @@ -42,6 +42,7 @@ gog docs (doc) insert-image --file=STRING [flags] | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | | `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) | +| `--url` | `string` | | Public HTTPS image URL to insert directly | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--width` | `float64` | 468 | Image width in points; default 468pt | diff --git a/docs/commands/gog-docs.md b/docs/commands/gog-docs.md index 732c115f0..38eb18daf 100644 --- a/docs/commands/gog-docs.md +++ b/docs/commands/gog-docs.md @@ -37,7 +37,7 @@ gog docs (doc) [flags] - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs insert-date-chip](gog-docs-insert-date-chip.md) - Insert a native date smart chip - [gog docs insert-file-chip](gog-docs-insert-file-chip.md) - Insert a native Drive file smart chip -- [gog docs insert-image](gog-docs-insert-image.md) - Upload a local image and insert it into a Google Doc +- [gog docs insert-image](gog-docs-insert-image.md) - Insert a public image URL or upload a local image into a Google Doc - [gog docs insert-page-break](gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) - [gog docs insert-person](gog-docs-insert-person.md) - Insert a native person smart chip - [gog docs insert-table](gog-docs-insert-table.md) - Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 33175e651..5f9515231 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -40,7 +40,7 @@ type DocsCmd struct { TableMerge DocsTableMergeCmd `cmd:"" name:"table-merge" help:"Merge a native table cell range"` TableUnmerge DocsTableUnmergeCmd `cmd:"" name:"table-unmerge" aliases:"table-split" help:"Unmerge the region containing a native table cell"` TableColumnWidth DocsTableColumnWidthCmd `cmd:"" name:"table-column-width" aliases:"table-width,column-width" help:"Set or reset native table column widths"` - InsertImage DocsInsertImageCmd `cmd:"" name:"insert-image" help:"Upload a local image and insert it into a Google Doc"` + InsertImage DocsInsertImageCmd `cmd:"" name:"insert-image" help:"Insert a public image URL or upload a local image into a Google Doc"` InsertPerson DocsInsertPersonCmd `cmd:"" name:"insert-person" help:"Insert a native person smart chip"` InsertFileChip DocsInsertFileChipCmd `cmd:"" name:"insert-file-chip" aliases:"insert-rich-link" help:"Insert a native Drive file smart chip"` InsertDateChip DocsInsertDateChipCmd `cmd:"" name:"insert-date-chip" help:"Insert a native date smart chip"` diff --git a/internal/cmd/docs_insert_image.go b/internal/cmd/docs_insert_image.go index a738eb645..593782f59 100644 --- a/internal/cmd/docs_insert_image.go +++ b/internal/cmd/docs_insert_image.go @@ -21,7 +21,8 @@ import ( type DocsInsertImageCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` - File string `name:"file" required:"" help:"Local PNG, JPEG, or GIF image to upload and insert" type:"existingfile"` + File string `name:"file" help:"Local PNG, JPEG, or GIF image to upload and insert" type:"existingfile"` + URL string `name:"url" help:"Public HTTPS image URL to insert directly"` At string `name:"at" help:"Placeholder text to replace, or 'end' to append" default:"end"` Width float64 `name:"width" help:"Image width in points; default 468pt" default:"468"` Height float64 `name:"height" help:"Image height in points (optional; width-only preserves aspect ratio)"` @@ -41,10 +42,17 @@ type docsInsertImageResult struct { requests int revoked bool fallbackLink bool + sourceURL string +} + +type docsInsertImageSource struct { + localPath string + name string + mimeType string + imageURL string } func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) docID := strings.TrimSpace(c.DocID) if docID == "" { return usage("empty docId") @@ -52,38 +60,37 @@ func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error { if c.Width < 0 || c.Height < 0 { return usage("--width and --height must be non-negative") } - localPath, err := config.ExpandPath(c.File) + source, err := c.resolveSource() if err != nil { return err } - mimeType := guessMimeType(localPath) - if !isDocsInsertImageMime(mimeType) { - return usage("--file must be a PNG, JPEG, or GIF image") - } - name := strings.TrimSpace(c.Name) - if name == "" { - name = filepath.Base(localPath) - } at := strings.TrimSpace(c.At) if at == "" { return usage("empty --at") } - if dryRunErr := dryRunExit(ctx, flags, "docs.insert-image", map[string]any{ - "documentId": docID, - "file": localPath, - "name": name, - "mimeType": mimeType, - "at": at, - "width": c.Width, - "height": c.Height, - "parent": c.Parent, - "onRestricted": c.OnRestricted, - "tab": c.Tab, - }); dryRunErr != nil { + dryRunPayload := map[string]any{ + "documentId": docID, + "at": at, + "width": c.Width, + "height": c.Height, + "tab": c.Tab, + } + if source.imageURL != "" { + dryRunPayload["url"] = source.imageURL + } else { + dryRunPayload["file"] = source.localPath + dryRunPayload["name"] = source.name + dryRunPayload["mimeType"] = source.mimeType + dryRunPayload["parent"] = c.Parent + dryRunPayload["onRestricted"] = c.OnRestricted + } + if dryRunErr := dryRunExit(ctx, flags, "docs.insert-image", dryRunPayload); dryRunErr != nil { return dryRunErr } - if confirmErr := confirmDestructiveChecked(ctx, flagsWithoutDryRun(flags), fmt.Sprintf("temporarily share uploaded image %s with anyone (public) so Google Docs can fetch it", name)); confirmErr != nil { - return confirmErr + if source.imageURL == "" { + if confirmErr := confirmDestructiveChecked(ctx, flagsWithoutDryRun(flags), fmt.Sprintf("temporarily share uploaded image %s with anyone (public) so Google Docs can fetch it", source.name)); confirmErr != nil { + return confirmErr + } } account, err := requireAccount(flags) @@ -94,25 +101,78 @@ func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - driveSvc, err := newDriveService(ctx, account) + + var result docsInsertImageResult + if source.imageURL != "" { + result, err = c.runURL(ctx, docsSvc, docID, source.imageURL, at) + } else { + driveSvc, driveErr := newDriveService(ctx, account) + if driveErr != nil { + return driveErr + } + result, err = c.runFile(ctx, docsSvc, driveSvc, docID, source.localPath, source.name, source.mimeType, at) + } if err != nil { return err } + return writeDocsInsertImageResult(ctx, result) +} + +func (c *DocsInsertImageCmd) resolveSource() (docsInsertImageSource, error) { + localFile := strings.TrimSpace(c.File) + imageURL := strings.TrimSpace(c.URL) + if localFile == "" && imageURL == "" { + return docsInsertImageSource{}, usage("required: --file or --url") + } + if localFile != "" && imageURL != "" { + return docsInsertImageSource{}, usage("--file and --url are mutually exclusive") + } + if imageURL != "" { + if strings.TrimSpace(c.Parent) != "" || strings.TrimSpace(c.Name) != "" || strings.EqualFold(c.OnRestricted, "link") { + return docsInsertImageSource{}, usage("--parent, --name, and --on-restricted=link require --file") + } + parsed, err := url.ParseRequestURI(imageURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil { + return docsInsertImageSource{}, usage("--url must be a public HTTPS image URL without embedded credentials") + } + return docsInsertImageSource{imageURL: parsed.String()}, nil + } - result, err := c.run(ctx, docsSvc, driveSvc, docID, localPath, name, mimeType, at) + localPath, err := config.ExpandPath(localFile) if err != nil { - return err + return docsInsertImageSource{}, err + } + mimeType := guessMimeType(localPath) + if !isDocsInsertImageMime(mimeType) { + return docsInsertImageSource{}, usage("--file must be a PNG, JPEG, or GIF image") + } + name := strings.TrimSpace(c.Name) + if name == "" { + name = filepath.Base(localPath) } + return docsInsertImageSource{ + localPath: localPath, + name: name, + mimeType: mimeType, + }, nil +} + +func writeDocsInsertImageResult(ctx context.Context, result docsInsertImageResult) error { + u := ui.FromContext(ctx) if outfmt.IsJSON(ctx) { payload := map[string]any{ - "documentId": result.documentID, - "uploadedFileId": result.uploadedFileID, - "uploadedFileName": result.uploadedFileName, - "permissionId": result.permissionID, - "atIndex": result.atIndex, - "requests": result.requests, - "revoked": result.revoked, - "fallbackLink": result.fallbackLink, + "documentId": result.documentID, + "atIndex": result.atIndex, + "requests": result.requests, + } + if result.sourceURL != "" { + payload["url"] = result.sourceURL + } else { + payload["uploadedFileId"] = result.uploadedFileID + payload["uploadedFileName"] = result.uploadedFileName + payload["permissionId"] = result.permissionID + payload["revoked"] = result.revoked + payload["fallbackLink"] = result.fallbackLink } if result.tabID != "" { payload["tabId"] = result.tabID @@ -120,20 +180,29 @@ func (c *DocsInsertImageCmd) Run(ctx context.Context, flags *RootFlags) error { return outfmt.WriteJSON(ctx, os.Stdout, payload) } u.Out().Linef("documentId\t%s", result.documentID) - u.Out().Linef("uploadedFileId\t%s", result.uploadedFileID) + if result.sourceURL != "" { + u.Out().Linef("url\t%s", result.sourceURL) + } else { + u.Out().Linef("uploadedFileId\t%s", result.uploadedFileID) + u.Out().Linef("revoked\t%t", result.revoked) + if result.fallbackLink { + u.Out().Linef("fallbackLink\ttrue") + } + } u.Out().Linef("atIndex\t%d", result.atIndex) u.Out().Linef("requests\t%d", result.requests) - u.Out().Linef("revoked\t%t", result.revoked) - if result.fallbackLink { - u.Out().Linef("fallbackLink\ttrue") - } if result.tabID != "" { u.Out().Linef("tabId\t%s", result.tabID) } return nil } -func (c *DocsInsertImageCmd) run(ctx context.Context, docsSvc *docs.Service, driveSvc *drive.Service, docID, localPath, name, mimeType, at string) (result docsInsertImageResult, err error) { +func (c *DocsInsertImageCmd) runURL(ctx context.Context, docsSvc *docs.Service, docID, imageURL, at string) (docsInsertImageResult, error) { + result := docsInsertImageResult{sourceURL: imageURL} + return c.insertImageURL(ctx, docsSvc, docID, imageURL, at, result) +} + +func (c *DocsInsertImageCmd) runFile(ctx context.Context, docsSvc *docs.Service, driveSvc *drive.Service, docID, localPath, name, mimeType, at string) (result docsInsertImageResult, err error) { uploaded, err := uploadDocsInlineImage(ctx, driveSvc, localPath, name, mimeType, strings.TrimSpace(c.Parent)) if err != nil { return result, err @@ -173,6 +242,10 @@ func (c *DocsInsertImageCmd) run(ctx context.Context, docsSvc *docs.Service, dri }() imageURL := driveImageDownloadURL(uploaded.Id) + return c.insertImageURL(ctx, docsSvc, docID, imageURL, at, result) +} + +func (c *DocsInsertImageCmd) insertImageURL(ctx context.Context, docsSvc *docs.Service, docID, imageURL, at string, result docsInsertImageResult) (docsInsertImageResult, error) { reqs, index, tabID, err := c.buildInsertRequests(ctx, docsSvc, docID, at, imageURL) if err != nil { return result, err diff --git a/internal/cmd/docs_insert_image_test.go b/internal/cmd/docs_insert_image_test.go new file mode 100644 index 000000000..3fa51f321 --- /dev/null +++ b/internal/cmd/docs_insert_image_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "google.golang.org/api/docs/v1" + "google.golang.org/api/drive/v3" +) + +func TestDocsInsertImageResolveSourceURL(t *testing.T) { + tests := []struct { + name string + cmd DocsInsertImageCmd + want string + }{ + {name: "missing", cmd: DocsInsertImageCmd{}, want: "required: --file or --url"}, + {name: "both", cmd: DocsInsertImageCmd{File: "image.png", URL: "https://example.com/image.png"}, want: "mutually exclusive"}, + {name: "http", cmd: DocsInsertImageCmd{URL: "http://example.com/image.png"}, want: "public HTTPS"}, + {name: "relative", cmd: DocsInsertImageCmd{URL: "image.png"}, want: "public HTTPS"}, + {name: "credentials", cmd: DocsInsertImageCmd{URL: "https://user:pass@example.com/image.png"}, want: "without embedded credentials"}, + {name: "parent", cmd: DocsInsertImageCmd{URL: "https://example.com/image.png", Parent: "folder1"}, want: "require --file"}, + {name: "name", cmd: DocsInsertImageCmd{URL: "https://example.com/image.png", Name: "image.png"}, want: "require --file"}, + {name: "restricted fallback", cmd: DocsInsertImageCmd{URL: "https://example.com/image.png", OnRestricted: "link"}, want: "require --file"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.cmd.resolveSource() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("resolveSource() error = %v, want %q", err, tt.want) + } + }) + } + + source, err := (&DocsInsertImageCmd{URL: "https://example.com/image.png?sig=abc"}).resolveSource() + if err != nil { + t.Fatalf("resolveSource valid URL: %v", err) + } + if source.imageURL != "https://example.com/image.png?sig=abc" || source.localPath != "" { + t.Fatalf("unexpected URL source: %#v", source) + } +} + +func TestDocsInsertImageURLRunSkipsDrive(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var got docs.BatchUpdateDocumentRequest + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"): + _ = json.NewEncoder(w).Encode(docBodyWithText("before\n")) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, ":batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + newDriveService = func(context.Context, string) (*drive.Service, error) { + t.Fatal("URL insertion must not create a Drive service") + return nil, errors.New("unexpected Drive service call") + } + + var runErr error + out := captureStdout(t, func() { + runErr = runKong(t, &DocsInsertImageCmd{}, []string{ + "doc1", + "--url", "https://example.com/image.png?sig=abc", + "--width", "320", + "--height", "180", + }, newDocsJSONContext(t), &RootFlags{Account: "a@b.com"}) + }) + if runErr != nil { + t.Fatalf("docs insert-image --url: %v", runErr) + } + if len(got.Requests) != 1 || got.Requests[0].InsertInlineImage == nil { + t.Fatalf("unexpected batch requests: %#v", got.Requests) + } + insert := got.Requests[0].InsertInlineImage + if insert.Uri != "https://example.com/image.png?sig=abc" || insert.Location.Index != 7 { + t.Fatalf("unexpected inline image request: %#v", insert) + } + if insert.ObjectSize.Width.Magnitude != 320 || insert.ObjectSize.Height.Magnitude != 180 { + t.Fatalf("unexpected inline image size: %#v", insert.ObjectSize) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode output: %v\n%s", err, out) + } + if payload["documentId"] != "doc1" || payload["url"] != "https://example.com/image.png?sig=abc" { + t.Fatalf("unexpected output: %#v", payload) + } + if _, ok := payload["uploadedFileId"]; ok { + t.Fatalf("URL output must not contain upload metadata: %#v", payload) + } +} + +func TestDocsInsertImageURLDryRunSkipsServices(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + newDocsService = func(context.Context, string) (*docs.Service, error) { + t.Fatal("dry-run must not create a Docs service") + return nil, errors.New("unexpected Docs service call") + } + newDriveService = func(context.Context, string) (*drive.Service, error) { + t.Fatal("dry-run must not create a Drive service") + return nil, errors.New("unexpected Drive service call") + } + + out := captureStdout(t, func() { + err := runKong(t, &DocsInsertImageCmd{}, []string{ + "doc1", + "--url", "https://example.com/image.png", + }, newDocsJSONContext(t), &RootFlags{Account: "a@b.com", DryRun: true, NoInput: true}) + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 0 { + t.Fatalf("dry-run error = %v", err) + } + }) + var payload struct { + Op string `json:"op"` + Request struct { + URL string `json:"url"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, out) + } + if payload.Op != "docs.insert-image" || payload.Request.URL != "https://example.com/image.png" { + t.Fatalf("unexpected dry-run output: %#v", payload) + } +}