diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 9b3529d..d24aeb7 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -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 } diff --git a/internal/cmd/docs_write_markdown_test.go b/internal/cmd/docs_write_markdown_test.go index 8c246f4..8c14852 100644 --- a/internal/cmd/docs_write_markdown_test.go +++ b/internal/cmd/docs_write_markdown_test.go @@ -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, "<