diff --git a/CHANGELOG.md b/CHANGELOG.md index c28c9797..ec16ed12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - CLI: harden backup writes, config/credentials atomic saves, keyring write verification, line input buffering, disabled-API hints, JSON transform number handling, and untrusted-content wrapping after ClawPatch review. - CLI: bound retry request replay buffering, recover failed async backup pushes, ignore global git commit signing in backup snapshots, and protect account manager OAuth redirects with CSRF checks. +- Docs: `docs write --append --markdown` now expands inline markdown markers (`**bold**`, `*italic*`, `` `code` ``, `[link](url)`) inside table cells into character runs, matching the behaviour outside of tables — previously the markers rendered as literal characters because the table inserter bypassed the inline-formatting pass. (#608) ## 0.17.0 - 2026-05-15 diff --git a/internal/cmd/docs_table_inserter.go b/internal/cmd/docs_table_inserter.go index 57c6d357..77e327d5 100644 --- a/internal/cmd/docs_table_inserter.go +++ b/internal/cmd/docs_table_inserter.go @@ -74,36 +74,9 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64 continue } - // Insert text into cell - insertTextReq := &docs.Request{ - InsertText: &docs.InsertTextRequest{ - Location: &docs.Location{ - Index: cellIdx, - }, - Text: cellContent, - }, - } - - // Make text bold if it's a header row - var boldReq *docs.Request - if rowIdx == 0 { - boldReq = &docs.Request{ - UpdateTextStyle: &docs.UpdateTextStyleRequest{ - Range: &docs.Range{ - StartIndex: cellIdx, - EndIndex: cellIdx + utf16Len(cellContent), - }, - TextStyle: &docs.TextStyle{ - Bold: true, - }, - Fields: "bold", - }, - } - } - - requests := []*docs.Request{insertTextReq} - if boldReq != nil { - requests = append(requests, boldReq) + requests, insertedLen := buildTableCellRequests(cellContent, cellIdx, rowIdx == 0) + if len(requests) == 0 { + continue } _, err := ti.svc.Documents.BatchUpdate(ti.docID, &docs.BatchUpdateDocumentRequest{ @@ -114,13 +87,56 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64 } // Update indices for subsequent cells (they shift by the content length) - ti.updateIndicesAfter(cellIdx, utf16Len(cellContent), cellIndices, &tableEndIndex) + ti.updateIndicesAfter(cellIdx, insertedLen, cellIndices, &tableEndIndex) } } return tableEndIndex, nil } +// buildTableCellRequests constructs the batch requests required to populate a +// single table cell, expanding inline markdown (**bold**, *italic*, `code`, +// [links]) into UpdateTextStyle requests on top of the inserted text. Header +// cells additionally receive a whole-cell bold style. Returns the requests and +// the UTF-16 length of the text that will be inserted so callers can keep +// running cell indices in sync. If the cell content strips to an empty string +// (e.g. content was only markers), returns (nil, 0). +func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool) ([]*docs.Request, int64) { + styles, stripped := ParseInlineFormatting(cellContent) + if stripped == "" { + return nil, 0 + } + + insertedLen := utf16Len(stripped) + requests := []*docs.Request{{ + InsertText: &docs.InsertTextRequest{ + Location: &docs.Location{Index: cellIdx}, + Text: stripped, + }, + }} + + if isHeaderRow { + requests = append(requests, &docs.Request{ + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Range: &docs.Range{ + StartIndex: cellIdx, + EndIndex: cellIdx + insertedLen, + }, + TextStyle: &docs.TextStyle{Bold: true}, + Fields: "bold", + }, + }) + } + + for _, style := range styles { + if req := buildTextStyleRequest(style, cellIdx, ""); req != nil { + requests = append(requests, req) + } + } + + return requests, insertedLen +} + // getTableCellIndices extracts the start index for each cell in a table func (ti *TableInserter) getTableCellIndices(doc *docs.Document, tableStartIndex int64, rows, cols int64) ([][]int64, int64, error) { cellIndices := make([][]int64, rows) diff --git a/internal/cmd/docs_table_inserter_inline_test.go b/internal/cmd/docs_table_inserter_inline_test.go new file mode 100644 index 00000000..a84f4171 --- /dev/null +++ b/internal/cmd/docs_table_inserter_inline_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "testing" + + "google.golang.org/api/docs/v1" +) + +// Regression for #608: inline markdown markers inside table cells previously +// rendered as literal characters because the inserter passed the cell content +// straight through to InsertText without running the same inline-formatting +// pass used by paragraphs/headings. + +func TestBuildTableCellRequests_AppliesInlineBold(t *testing.T) { + reqs, inserted := buildTableCellRequests("**Alice**", 100, false) + + if inserted != utf16Len("Alice") { + t.Fatalf("expected inserted len = utf16Len(\"Alice\") = %d, got %d", utf16Len("Alice"), inserted) + } + + if len(reqs) != 2 { + t.Fatalf("expected 2 requests (InsertText + UpdateTextStyle), got %d: %#v", len(reqs), reqs) + } + + insert := reqs[0].InsertText + if insert == nil || insert.Text != "Alice" { + t.Fatalf("expected InsertText with stripped text \"Alice\", got %#v", reqs[0]) + } + if insert.Location == nil || insert.Location.Index != 100 { + t.Fatalf("expected insert at index 100, got %#v", insert.Location) + } + + style := reqs[1].UpdateTextStyle + if style == nil || style.TextStyle == nil || !style.TextStyle.Bold { + t.Fatalf("expected UpdateTextStyle with Bold:true, got %#v", reqs[1]) + } + if style.Range == nil || style.Range.StartIndex != 100 || style.Range.EndIndex != 100+utf16Len("Alice") { + t.Fatalf("expected style range [100,105], got %#v", style.Range) + } +} + +func TestBuildTableCellRequests_AppliesInlineItalicAndCode(t *testing.T) { + cases := []struct { + name string + cell string + wantText string + wantBold bool + wantItalic bool + wantCode bool + }{ + {"italic", "*important*", "important", false, true, false}, + {"code", "`xyz123`", "xyz123", false, false, true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + reqs, inserted := buildTableCellRequests(tt.cell, 50, false) + if inserted != utf16Len(tt.wantText) { + t.Fatalf("inserted = %d, want %d", inserted, utf16Len(tt.wantText)) + } + if len(reqs) < 2 { + t.Fatalf("expected >=2 requests, got %d: %#v", len(reqs), reqs) + } + if got := reqs[0].InsertText; got == nil || got.Text != tt.wantText { + t.Fatalf("InsertText.Text = %q, want %q", textOf(reqs[0]), tt.wantText) + } + style := reqs[1].UpdateTextStyle + if style == nil || style.TextStyle == nil { + t.Fatalf("expected UpdateTextStyle, got %#v", reqs[1]) + } + if tt.wantItalic && !style.TextStyle.Italic { + t.Fatalf("expected Italic, got %#v", style.TextStyle) + } + if tt.wantCode && style.TextStyle.WeightedFontFamily == nil { + t.Fatalf("expected code styling (WeightedFontFamily), got %#v", style.TextStyle) + } + }) + } +} + +func TestBuildTableCellRequests_HeaderRowAppliesBoldOverWholeCell(t *testing.T) { + reqs, inserted := buildTableCellRequests("Field", 10, true) + + if inserted != utf16Len("Field") { + t.Fatalf("inserted = %d, want %d", inserted, utf16Len("Field")) + } + if len(reqs) != 2 { + t.Fatalf("expected 2 requests (InsertText + header bold), got %d: %#v", len(reqs), reqs) + } + style := reqs[1].UpdateTextStyle + if style == nil || style.TextStyle == nil || !style.TextStyle.Bold { + t.Fatalf("expected header-row UpdateTextStyle with Bold:true, got %#v", reqs[1]) + } + if style.Range == nil || style.Range.StartIndex != 10 || style.Range.EndIndex != 10+utf16Len("Field") { + t.Fatalf("expected style range [10,15], got %#v", style.Range) + } +} + +func TestBuildTableCellRequests_PlainTextNoStyleRequest(t *testing.T) { + reqs, inserted := buildTableCellRequests("plain text", 1, false) + if inserted != utf16Len("plain text") { + t.Fatalf("inserted = %d, want %d", inserted, utf16Len("plain text")) + } + if len(reqs) != 1 { + t.Fatalf("expected just InsertText, got %d: %#v", len(reqs), reqs) + } + if got := reqs[0].InsertText; got == nil || got.Text != "plain text" { + t.Fatalf("InsertText.Text = %q, want %q", textOf(reqs[0]), "plain text") + } +} + +func TestBuildTableCellRequests_EmptyAfterStrippingReturnsNothing(t *testing.T) { + // A cell whose entire content is markers (e.g. "**") would strip to "". + reqs, inserted := buildTableCellRequests("", 1, false) + if len(reqs) != 0 || inserted != 0 { + t.Fatalf("expected (nil, 0) for empty cell, got reqs=%#v inserted=%d", reqs, inserted) + } +} + +func textOf(r *docs.Request) string { + if r == nil || r.InsertText == nil { + return "" + } + return r.InsertText.Text +}