fix(docs): size markdown images on write

This commit is contained in:
Vinoth Deivasigamani 2026-04-21 00:19:39 -07:00 committed by Peter Steinberger
parent cb817fe65a
commit e97cad62f6
No known key found for this signature in database
2 changed files with 262 additions and 6 deletions

View File

@ -11,6 +11,7 @@ import (
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
@ -40,7 +41,11 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
return usage("--append cannot be combined with --replace")
}
if c.Markdown {
return c.writeMarkdown(ctx, flags, id, text)
basePath, baseErr := c.markdownBasePath()
if baseErr != nil {
return baseErr
}
return c.writeMarkdown(ctx, flags, id, text, basePath)
}
return c.writePlainText(ctx, flags, id, text)
@ -60,6 +65,18 @@ func (c *DocsWriteCmd) resolveWriteText(kctx *kong.Context) (string, error) {
return text, nil
}
func (c *DocsWriteCmd) markdownBasePath() (string, error) {
file := strings.TrimSpace(c.File)
if file == "" || file == "-" {
return ".", nil
}
expanded, err := config.ExpandPath(file)
if err != nil {
return "", err
}
return expanded, nil
}
func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, docID, text string) error {
svc, err := requireDocsService(ctx, flags)
if err != nil {
@ -152,7 +169,7 @@ func (c *DocsWriteCmd) writePlainTextResult(ctx context.Context, resp *docs.Batc
return nil
}
func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error {
func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docID, content string, basePath string) error {
u := ui.FromContext(ctx)
if !c.Replace {
@ -165,13 +182,15 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI
return usage("--markdown cannot be combined with --tab-id")
}
_, driveSvc, err := requireDriveService(ctx, flags)
cleaned, images := extractMarkdownImages(content)
account, driveSvc, err := requireDriveService(ctx, flags)
if err != nil {
return err
}
updated, err := driveSvc.Files.Update(docID, &drive.File{}).
Media(strings.NewReader(content), gapi.ContentType(mimeTextMarkdown)).
Media(strings.NewReader(cleaned), gapi.ContentType(mimeTextMarkdown)).
SupportsAllDrives(true).
Fields("id,name,webViewLink").
Context(ctx).
@ -180,11 +199,21 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI
return fmt.Errorf("writing markdown to document: %w", err)
}
if c.Pageless {
docsSvc, svcErr := requireDocsService(ctx, flags)
var docsSvc *docs.Service
if len(images) > 0 || c.Pageless {
var svcErr error
docsSvc, svcErr = newDocsService(ctx, account)
if svcErr != nil {
return svcErr
}
}
if len(images) > 0 {
if err := insertImagesIntoDocs(ctx, account, docsSvc, docID, images, basePath); err != nil {
cleanupDocsImagePlaceholders(ctx, docsSvc, docID, images)
return fmt.Errorf("insert images: %w", err)
}
}
if c.Pageless {
if err := c.applyPageless(ctx, docsSvc, docID); err != nil {
return err
}

View File

@ -91,3 +91,230 @@ func TestDocsWrite_MarkdownReplaceUsesDriveUpdate(t *testing.T) {
t.Fatalf("expected upload body to contain markdown content, got: %q", uploadBody)
}
}
func TestDocsWrite_MarkdownImagesInsertedAfterDriveUpdate(t *testing.T) {
origDocs := newDocsService
origDrive := newDriveService
t.Cleanup(func() {
newDocsService = origDocs
newDriveService = origDrive
})
var uploadBody string
var sawDocsGet bool
var batchReq docs.BatchUpdateDocumentRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/upload/drive/v3/files/doc1"):
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
uploadBody = string(body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "doc1",
"name": "Doc",
"webViewLink": "https://docs.google.com/document/d/doc1/edit",
})
return
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"):
sawDocsGet = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(docBodyWithText(uploadBody))
return
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"):
if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil {
t.Fatalf("decode batch update: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/drive/v3/"),
)
if err != nil {
t.Fatalf("NewDriveService: %v", err)
}
docsSvc, err := docs.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewDocsService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
newDocsService = func(context.Context, string) (*docs.Service, error) { return docsSvc, nil }
markdown := strings.Join([]string{
"# Images",
"![default](https://example.com/default.png)",
"![wide](https://example.com/wide.png){width=200}",
"![sized](https://example.com/sized.png){width=200 height=150}",
"",
}, "\n")
flags := &RootFlags{Account: "a@b.com"}
ctx := newDocsJSONContext(t)
if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", markdown, "--replace", "--markdown"}, ctx, flags); err != nil {
t.Fatalf("markdown replace write: %v", err)
}
if strings.Contains(uploadBody, "![default]") || strings.Contains(uploadBody, "![wide]") || strings.Contains(uploadBody, "![sized]") {
t.Fatalf("expected drive update body to use placeholders, got: %q", uploadBody)
}
if count := strings.Count(uploadBody, "<<IMG_"); count != 3 {
t.Fatalf("expected 3 image placeholders in drive update body, got %d in %q", count, uploadBody)
}
if !sawDocsGet {
t.Fatal("expected image insertion path to read the document")
}
inserts := map[string]*docs.InsertInlineImageRequest{}
for _, req := range batchReq.Requests {
if req.InsertInlineImage != nil {
inserts[req.InsertInlineImage.Uri] = req.InsertInlineImage
}
}
if len(inserts) != 3 {
t.Fatalf("expected 3 inserted images, got %d", len(inserts))
}
assertImageSize(t, inserts["https://example.com/default.png"], defaultImageMaxWidthPt, 0)
assertImageSize(t, inserts["https://example.com/wide.png"], 200, 0)
assertImageSize(t, inserts["https://example.com/sized.png"], 200, 150)
}
func TestDocsWrite_MarkdownLocalImagesResolveRelativeToSourceFile(t *testing.T) {
origDocs := newDocsService
origDrive := newDriveService
t.Cleanup(func() {
newDocsService = origDocs
newDriveService = origDrive
})
tmpDir := t.TempDir()
imgDir := filepath.Join(tmpDir, "assets")
if err := os.Mkdir(imgDir, 0o700); err != nil {
t.Fatalf("mkdir assets: %v", err)
}
imagePath := filepath.Join(imgDir, "local.png")
if err := os.WriteFile(imagePath, []byte("png"), 0o600); err != nil {
t.Fatalf("write image: %v", err)
}
mdFile := filepath.Join(tmpDir, "source.md")
if err := os.WriteFile(mdFile, []byte("![local](assets/local.png)\n"), 0o600); err != nil {
t.Fatalf("write markdown: %v", err)
}
var uploadBody string
var uploadedImageName string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/upload/drive/v3/files/doc1"):
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read markdown upload body: %v", err)
}
uploadBody = string(body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "doc1", "name": "Doc"})
return
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(docBodyWithText(uploadBody))
return
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/upload/drive/v3/files"):
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read image upload body: %v", err)
}
if !strings.Contains(string(body), "png") {
t.Fatalf("expected local image file contents in upload body, got %q", string(body))
}
uploadedImageName = "local.png"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "img1",
"webContentLink": "https://drive.google.com/uc?id=img1",
})
return
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/drive/v3/files/img1/permissions"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "perm1"})
return
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
return
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/drive/v3/files/img1"):
w.WriteHeader(http.StatusNoContent)
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
driveSvc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/drive/v3/"),
)
if err != nil {
t.Fatalf("NewDriveService: %v", err)
}
docsSvc, err := docs.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewDocsService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil }
newDocsService = func(context.Context, string) (*docs.Service, error) { return docsSvc, nil }
flags := &RootFlags{Account: "a@b.com"}
ctx := newDocsJSONContext(t)
if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--file", mdFile, "--replace", "--markdown"}, ctx, flags); err != nil {
t.Fatalf("markdown replace write: %v", err)
}
if uploadedImageName != "local.png" {
t.Fatalf("expected local image upload from markdown directory, got %q", uploadedImageName)
}
}
func assertImageSize(t *testing.T, ins *docs.InsertInlineImageRequest, wantWidth, wantHeight float64) {
t.Helper()
if ins == nil {
t.Fatal("missing inserted image request")
}
if wantWidth == 0 {
if ins.ObjectSize.Width != nil {
t.Fatalf("expected no width, got %+v", ins.ObjectSize.Width)
}
} else if ins.ObjectSize.Width == nil || ins.ObjectSize.Width.Magnitude != wantWidth || ins.ObjectSize.Width.Unit != "PT" {
t.Fatalf("expected width=%v PT, got %+v", wantWidth, ins.ObjectSize.Width)
}
if wantHeight == 0 {
if ins.ObjectSize.Height != nil {
t.Fatalf("expected no height, got %+v", ins.ObjectSize.Height)
}
} else if ins.ObjectSize.Height == nil || ins.ObjectSize.Height.Magnitude != wantHeight || ins.ObjectSize.Height.Unit != "PT" {
t.Fatalf("expected height=%v PT, got %+v", wantHeight, ins.ObjectSize.Height)
}
}