Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### Fixed

- Docs: batch table-cell writes for `docs write --tab --markdown` to avoid per-cell Docs API quota bursts on table-heavy documents. (#699) — thanks @sebsnyk.
- Gmail: preserve existing `gmail drafts update` attachments when `--attach` is omitted, add `--clear-attachments` for intentional removal, and keep `--attach` as explicit replacement. (#680, #681) — thanks @chrischall.

## 0.21.0 - 2026-06-01
Expand Down
122 changes: 97 additions & 25 deletions internal/cmd/docs_append_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,14 @@ func newFakeDocsTableSvc(t *testing.T, body string, drift int64) (*docs.Service,
return svc, f
}

// TestInsertDocsMarkdownAt_AppendsTable_IssueRepro replays the exact repro
// from #592 — a table-only markdown file appended to a doc via the same code
// path `gog docs write --markdown --append` exercises. The fake server reports
// the inserted table with a drift of 5 from the requested Location.Index, well
// outside the original ±2 search window; without the fix this fails with
// "insert native table: table not found near index 9".
// TestInsertDocsMarkdownAt_AppendsTable_IssueRepro replays the original #592
// repro — a table-only markdown file appended to a doc via the same code
// path `gog docs write --markdown --append` exercises. After the #699
// collapse the table's per-cell inserts are folded into ONE batchUpdate
// (was: one per cell), so a single-table append produces three batchUpdates:
// the body InsertText, the InsertTable structure, and the consolidated
// per-cell content. Multi-table bodies scale at 1 + 2 * tables instead of
// O(cell-count).
func TestInsertDocsMarkdownAt_AppendsTable_IssueRepro(t *testing.T) {
svc, fake := newFakeDocsTableSvc(t, "Existing\n", 5)

Expand All @@ -200,22 +202,79 @@ func TestInsertDocsMarkdownAt_AppendsTable_IssueRepro(t *testing.T) {
t.Fatalf("expected 3x3 table, got rows=%d cols=%d", fake.tableRows, fake.tableCols)
}

if len(fake.batchCalls) < 2 {
t.Fatalf("expected at least 2 batchUpdate calls (text + table), got %d", len(fake.batchCalls))
// Wire profile: body + InsertTable + consolidated cell content = 3 calls.
// Was O(cell-count) per cell pre-#699; the per-cell loop was the quota
// burn that this PR collapses. Three calls is the floor while we still
// need a Get-round-trip to discover actual cell indices after InsertTable.
if len(fake.batchCalls) != 3 {
t.Fatalf("expected exactly 3 batchUpdate calls (body, InsertTable, cells), got %d", len(fake.batchCalls))
}

first := fake.batchCalls[0]
var sawInsertText bool
for _, rq := range first {
body := fake.batchCalls[0]
if len(body) == 0 || body[0].InsertText == nil {
t.Fatalf("first batch should start with InsertText, got %#v", body)
}
if body[0].InsertText.Location.Index != insertIdx {
t.Fatalf("body InsertText at %d, want %d", body[0].InsertText.Location.Index, insertIdx)
}

tableBatch := fake.batchCalls[1]
var sawInsertTable bool
for _, rq := range tableBatch {
if rq.InsertTable != nil {
sawInsertTable = true
break
}
}
if !sawInsertTable {
t.Fatalf("second batch should carry InsertTable, got %#v", tableBatch)
}

// Third batch carries all the per-cell content as one batch (the #699
// collapse). Expect at least one InsertText for the cells.
cellBatch := fake.batchCalls[2]
var sawCellInsertText bool
for _, rq := range cellBatch {
if rq.InsertText != nil {
sawInsertText = true
if rq.InsertText.Location.Index != insertIdx {
t.Fatalf("first InsertText at %d, want %d", rq.InsertText.Location.Index, insertIdx)
}
sawCellInsertText = true
break
}
}
if !sawInsertText {
t.Fatalf("first batch should carry InsertText, got %#v", first)
if !sawCellInsertText {
t.Fatalf("third batch should carry the cell InsertText content, got %#v", cellBatch)
}
}

func TestInsertNativeTableChunksLargeCellBatch(t *testing.T) {
svc, fake := newFakeDocsTableSvc(t, "Existing\n", 0)

cols := docsBatchUpdateRequestCap/2 + 1
cells := make([][]string, 1)
cells[0] = make([]string, cols)
for i := range cells[0] {
cells[0][i] = "header"
}

tableEnd, err := NewTableInserter(svc, "doc1").InsertNativeTable(context.Background(), 9, cells, "")
if err != nil {
t.Fatalf("InsertNativeTable: %v", err)
}
if tableEnd <= 9 {
t.Fatalf("expected table end to advance, got %d", tableEnd)
}
if len(fake.batchCalls) != 3 {
t.Fatalf("expected table insert plus two cell-content chunks, got %d", len(fake.batchCalls))
}
if len(fake.batchCalls[1]) != docsBatchUpdateRequestCap {
t.Fatalf("first cell chunk has %d requests, want %d", len(fake.batchCalls[1]), docsBatchUpdateRequestCap)
}
if len(fake.batchCalls[2]) != 2 {
t.Fatalf("second cell chunk has %d requests, want 2", len(fake.batchCalls[2]))
}
for i, batch := range fake.batchCalls {
if len(batch) > docsBatchUpdateRequestCap {
t.Fatalf("batch %d exceeded cap: %d", i, len(batch))
}
}
}

Expand Down Expand Up @@ -368,8 +427,11 @@ func TestPickTableNear_IgnoresWrongDimensions(t *testing.T) {
}

// TestInsertDocsMarkdownAt_TableErrorIsActionable guards the wrapped error
// message so the original symptom of #592 stays diagnostically searchable in
// logs when the Docs API genuinely does not produce a table.
// message that the markdown append path surfaces when the Docs API rejects
// the batchUpdate carrying the InsertTableRequest (for example, when the
// server cannot place the table at the requested location). After #699 the
// body + table fold into a single batchUpdate so the error wrap is
// "append (markdown):" rather than the old per-table "insert native table:".
func TestInsertDocsMarkdownAt_TableErrorIsActionable(t *testing.T) {
var batchCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -380,7 +442,14 @@ func TestInsertDocsMarkdownAt_TableErrorIsActionable(t *testing.T) {
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"):
batchCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": 400,
"message": "Invalid requests[1].insertTable: cannot insert table at requested location",
"status": "INVALID_ARGUMENT",
},
})
default:
http.NotFound(w, r)
}
Expand All @@ -399,12 +468,15 @@ func TestInsertDocsMarkdownAt_TableErrorIsActionable(t *testing.T) {
markdown := "| a | b |\n|---|---|\n| 1 | 2 |\n"
_, _, err = insertDocsMarkdownAt(context.Background(), svc, "doc1", 9, markdown, "")
if err == nil {
t.Fatal("expected error when server has no table; got nil")
t.Fatal("expected error from Docs API rejection; got nil")
}
if !strings.Contains(err.Error(), "append (markdown)") {
t.Fatalf("error should be wrapped with 'append (markdown):'; got %v", err)
}
if !strings.Contains(err.Error(), "insert native table") {
t.Fatalf("error should be wrapped with 'insert native table'; got %v", err)
if !strings.Contains(err.Error(), "insertTable") {
t.Fatalf("error should surface the underlying Docs API message about insertTable; got %v", err)
}
if batchCalls < 2 {
t.Fatalf("expected >=2 batchUpdate calls before failure, got %d", batchCalls)
if batchCalls != 1 {
t.Fatalf("expected exactly 1 batchUpdate call (body + table folded), got %d", batchCalls)
}
}
139 changes: 99 additions & 40 deletions internal/cmd/docs_mutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ import (
"context"
"errors"
"fmt"
"os"
"strings"

"google.golang.org/api/docs/v1"
)

// docsBatchUpdateRequestCap is the Docs API hard limit on the number of
// requests a single documents.batchUpdate may carry. When the consolidated
// body + table + formatting request list exceeds it we chunk into multiple
// sequential batchUpdate calls, preserving the request order so cell-index
// arithmetic stays consistent. See #699.
const docsBatchUpdateRequestCap = 500

const (
docsContentFormatPlain = "plain"
docsContentFormatMarkdown = "markdown"
Expand Down Expand Up @@ -127,21 +135,13 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.
}
formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, baseIndex, tabID)

for _, req := range formattingRequests {
if req.UpdateTextStyle != nil && req.UpdateTextStyle.Range != nil {
req.UpdateTextStyle.Range.TabId = tabID
}
if req.UpdateParagraphStyle != nil && req.UpdateParagraphStyle.Range != nil {
req.UpdateParagraphStyle.Range.TabId = tabID
}
if req.CreateParagraphBullets != nil && req.CreateParagraphBullets.Range != nil {
req.CreateParagraphBullets.Range.TabId = tabID
}
if req.DeleteParagraphBullets != nil && req.DeleteParagraphBullets.Range != nil {
req.DeleteParagraphBullets.Range.TabId = tabID
}
}
applyTabIDToFormattingRequests(formattingRequests, tabID)

// Structural DeleteContentRange + body InsertText + per-element formatting
// go in one batchUpdate. Tables are inserted afterwards via InsertNativeTable
// (which does its own InsertTable + Get + batched cell-content per table —
// see #699 follow-up: cross-table prediction was unreliable, server-readback
// per table is correct).
requests := make([]*docs.Request, 0, 2+len(formattingRequests))
requests = append(requests, &docs.Request{
DeleteContentRange: &docs.DeleteContentRangeRequest{
Expand All @@ -158,14 +158,10 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.
requests = append(requests, formattingRequests...)
}

_, err = svc.Documents.BatchUpdate(doc.DocumentId, &docs.BatchUpdateDocumentRequest{
WriteControl: &docs.WriteControl{RequiredRevisionId: doc.RevisionId},
Requests: requests,
}).Context(ctx).Do()
requestCount, err = submitBatchedDocsRequests(ctx, svc, doc.DocumentId, requests, &docs.WriteControl{RequiredRevisionId: doc.RevisionId})
if err != nil {
return 0, 0, fmt.Errorf("replace (markdown): %w", err)
}
requestCount = len(requests)

if len(tables) > 0 {
tableInserter := NewTableInserter(svc, doc.DocumentId)
Expand All @@ -174,7 +170,7 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.
tableIndex := table.StartIndex + tableOffset
tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells, tabID)
if tableErr != nil {
return requestCount, len(prefix) + len(textToInsert), fmt.Errorf("insert native table: %w", tableErr)
return requestCount, len(textToInsert), fmt.Errorf("insert native table: %w", tableErr)
}
tableOffset = nextTableInsertOffset(tableOffset, tableIndex, tableEnd)
}
Expand Down Expand Up @@ -209,21 +205,11 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string,
return 0, 0, nil
}

for _, req := range formattingRequests {
if req.UpdateTextStyle != nil && req.UpdateTextStyle.Range != nil {
req.UpdateTextStyle.Range.TabId = tabID
}
if req.UpdateParagraphStyle != nil && req.UpdateParagraphStyle.Range != nil {
req.UpdateParagraphStyle.Range.TabId = tabID
}
if req.CreateParagraphBullets != nil && req.CreateParagraphBullets.Range != nil {
req.CreateParagraphBullets.Range.TabId = tabID
}
if req.DeleteParagraphBullets != nil && req.DeleteParagraphBullets.Range != nil {
req.DeleteParagraphBullets.Range.TabId = tabID
}
}
applyTabIDToFormattingRequests(formattingRequests, tabID)

// Body InsertText + per-element formatting in one batchUpdate. Tables
// follow via InsertNativeTable (one InsertTable + one cell batch per
// table — see #699 follow-up).
requests := make([]*docs.Request, 0, 1+len(formattingRequests))
requests = append(requests, &docs.Request{
InsertText: &docs.InsertTextRequest{
Expand All @@ -233,9 +219,7 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string,
})
requests = append(requests, formattingRequests...)

_, err = svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: requests,
}).Context(ctx).Do()
requestCount, err = submitBatchedDocsRequests(ctx, svc, docID, requests, nil)
if err != nil {
return 0, 0, fmt.Errorf("append (markdown): %w", err)
}
Expand All @@ -247,7 +231,7 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string,
tableIndex := table.StartIndex + tableOffset
tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells, tabID)
if tableErr != nil {
return len(requests), len(textToInsert), fmt.Errorf("insert native table: %w", tableErr)
return requestCount, len(textToInsert), fmt.Errorf("insert native table: %w", tableErr)
}
tableOffset = nextTableInsertOffset(tableOffset, tableIndex, tableEnd)
}
Expand All @@ -257,11 +241,86 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string,
imgErr := insertImagesIntoDocs(ctx, svc, docID, images, tabID)
cleanupDocsImagePlaceholders(ctx, svc, docID, images, tabID)
if imgErr != nil {
return len(requests), len(textToInsert), fmt.Errorf("insert images: %w", imgErr)
return requestCount, len(prefix) + len(textToInsert), fmt.Errorf("insert images: %w", imgErr)
}
}

return len(requests), len(prefix) + len(textToInsert), nil
return requestCount, len(prefix) + len(textToInsert), nil
}

// applyTabIDToFormattingRequests propagates tabID to every request whose
// range needs to be tab-scoped. Centralised so both the append and replace
// markdown paths stay in sync — previously each duplicated the same eight
// nil-guarded assignments inline.
func applyTabIDToFormattingRequests(requests []*docs.Request, tabID string) {
if tabID == "" {
return
}
for _, req := range requests {
if req == nil {
continue
}
if req.UpdateTextStyle != nil && req.UpdateTextStyle.Range != nil {
req.UpdateTextStyle.Range.TabId = tabID
}
if req.UpdateParagraphStyle != nil && req.UpdateParagraphStyle.Range != nil {
req.UpdateParagraphStyle.Range.TabId = tabID
}
if req.CreateParagraphBullets != nil && req.CreateParagraphBullets.Range != nil {
req.CreateParagraphBullets.Range.TabId = tabID
}
if req.DeleteParagraphBullets != nil && req.DeleteParagraphBullets.Range != nil {
req.DeleteParagraphBullets.Range.TabId = tabID
}
}
}

// submitBatchedDocsRequests sends the supplied request list as one or more
// documents.batchUpdate calls, splitting at docsBatchUpdateRequestCap-sized
// chunks when the consolidated request count exceeds the Docs API per-batch
// hard limit. Each chunk preserves the source order so cell-index
// arithmetic remains consistent across the split. Returns the total number
// of requests submitted (matches len(requests) on success); chunk events are
// announced on stderr so callers can correlate wire traffic with logs.
func submitBatchedDocsRequests(ctx context.Context, svc *docs.Service, docID string, requests []*docs.Request, writeControl *docs.WriteControl) (int, error) {
if len(requests) == 0 {
return 0, nil
}
if len(requests) <= docsBatchUpdateRequestCap {
_, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
WriteControl: writeControl,
Requests: requests,
}).Context(ctx).Do()
if err != nil {
return 0, err
}
return len(requests), nil
}

totalChunks := (len(requests) + docsBatchUpdateRequestCap - 1) / docsBatchUpdateRequestCap
for i := 0; i < len(requests); i += docsBatchUpdateRequestCap {
end := i + docsBatchUpdateRequestCap
if end > len(requests) {
end = len(requests)
}
chunkIdx := i/docsBatchUpdateRequestCap + 1
fmt.Fprintf(os.Stderr, "gog: docs batchUpdate split %d/%d (%d requests; Docs API per-call cap is %d)\n",
chunkIdx, totalChunks, end-i, docsBatchUpdateRequestCap)
// WriteControl is only meaningful on the first chunk — subsequent
// chunks operate on whatever revision the prior chunk produced.
var wc *docs.WriteControl
if i == 0 {
wc = writeControl
}
_, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
WriteControl: wc,
Requests: requests[i:end],
}).Context(ctx).Do()
if err != nil {
return i, err
}
}
return len(requests), nil
}

func markdownAppendNeedsParagraphBoundary(elements []MarkdownElement) bool {
Expand Down
Loading
Loading