Skip to content

Commit 7e15166

Browse files
authored
fix(docs): preserve nested lists in table cells (#761)
1 parent 5614dd0 commit 7e15166

6 files changed

Lines changed: 143 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- CLI: remove the separate `gog agent` and `exit-codes` helpers; expose stable exit codes and effective automation safety state through `gog schema --json`, and summarize the contract in root help. (#677)
1010
- CLI: add Git-style `gog help <command>`, make explicit output flags override environment defaults, validate color and JSON-only transforms before command execution, report early usage errors on stderr, and reject contradictory schema plain output.
1111
- Docs: prevent multi-paragraph Markdown range replacements from inheriting the matched paragraph's heading or list style. (#756) — thanks @sebsnyk.
12+
- Docs: preserve nested Markdown list levels as native bullets inside imported and updated table cells. (#749) — thanks @sebsnyk.
1213

1314
## 0.24.0 - 2026-06-11
1415

docs/docs-editing.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,9 @@ gog docs cell-update <docId> --table-index 1 --row 2 --col 3 \
212212

213213
Coordinates are 1-based. `--tab` targets a specific tab, and `--append` inserts
214214
at the end of the cell instead of replacing its current content. Markdown list
215-
content creates native Google Docs bullets or numbering inside the cell.
215+
content creates native Google Docs bullets or numbering inside the cell,
216+
including nested levels. Markdown table imports preserve the same nested list
217+
structure inside cells.
216218

217219
Set or reset native table column widths after inserting or importing tables:
218220

internal/cmd/docs_cell_update.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,13 @@ func updateDocsCellContent(ctx context.Context, svc *docs.Service, doc *docs.Doc
182182
baseIndex++
183183
prefix = "\n"
184184
}
185-
formatReqs, textToInsert, tables := MarkdownToDocsRequests(ParseMarkdown(content), baseIndex, tabID)
186-
if len(tables) > 0 {
187-
return usage("markdown tables are not supported inside table cells")
185+
formatReqs, textToInsert, _, formatErr := buildMarkdownCellContent(content, baseIndex, tabID)
186+
if formatErr != nil {
187+
return formatErr
188188
}
189-
textToInsert = strings.TrimSuffix(textToInsert, "\n")
190189
if textToInsert == "" {
191190
return usage("markdown content produced no editable cell text")
192191
}
193-
formatReqs = clampDocsCellFormatRequests(formatReqs, baseIndex+utf16Len(textToInsert))
194192
requests = append(requests, &docs.Request{
195193
InsertText: &docs.InsertTextRequest{
196194
Location: &docs.Location{Index: startIdx, TabId: tabID},
@@ -220,6 +218,29 @@ func updateDocsCellContent(ctx context.Context, svc *docs.Service, doc *docs.Doc
220218
return nil
221219
}
222220

221+
func buildMarkdownCellContent(content string, baseIndex int64, tabID string) ([]*docs.Request, string, int64, error) {
222+
elements := ParseMarkdown(content)
223+
formatReqs, text, tables := MarkdownToDocsRequests(elements, baseIndex, tabID)
224+
if len(tables) > 0 {
225+
return nil, "", 0, usage("markdown tables are not supported inside table cells")
226+
}
227+
text = strings.TrimSuffix(text, "\n")
228+
if text == "" {
229+
return nil, "", 0, nil
230+
}
231+
formatReqs = clampDocsCellFormatRequests(formatReqs, baseIndex+utf16Len(text))
232+
233+
// CreateParagraphBullets consumes one leading tab per nesting level.
234+
// Report final document growth so later native-table offsets stay exact.
235+
insertedLen := utf16Len(text)
236+
for _, element := range elements {
237+
if (element.Type == MDListItem || element.Type == MDNumberedList) && element.Level > 0 {
238+
insertedLen -= int64(element.Level)
239+
}
240+
}
241+
return formatReqs, text, insertedLen, nil
242+
}
243+
223244
func markdownCellAppendNeedsBoundary(elements []MarkdownElement) bool {
224245
if len(elements) != 1 {
225246
return len(elements) > 1

internal/cmd/docs_cell_update_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,46 @@ func TestDocsCellUpdate_ReplacesWithNativeMarkdownList(t *testing.T) {
150150
}
151151
}
152152

153+
func TestDocsCellUpdate_ReplacesWithNestedNativeMarkdownList(t *testing.T) {
154+
origDocs := newDocsService
155+
t.Cleanup(func() { newDocsService = origDocs })
156+
157+
var got docs.BatchUpdateDocumentRequest
158+
docSvc, cleanup := newDocsServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
159+
w.Header().Set("Content-Type", "application/json")
160+
switch {
161+
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"):
162+
_ = json.NewEncoder(w).Encode(cellUpdateTestDoc())
163+
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, ":batchUpdate"):
164+
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
165+
t.Fatalf("decode batchUpdate: %v", err)
166+
}
167+
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
168+
default:
169+
http.NotFound(w, r)
170+
}
171+
})
172+
defer cleanup()
173+
newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil }
174+
175+
cmd := &DocsCellUpdateCmd{}
176+
content := "- a\n - a1\n - a2\n- b"
177+
if err := runKong(t, cmd, []string{"doc1", "--row", "1", "--col", "2", "--content=" + content}, newDocsCmdContext(t), &RootFlags{Account: "a@b.com"}); err != nil {
178+
t.Fatalf("docs cell-update nested list: %v", err)
179+
}
180+
if len(got.Requests) != 3 {
181+
t.Fatalf("expected delete+insert+bullets, got %d requests", len(got.Requests))
182+
}
183+
ins := got.Requests[1].InsertText
184+
if ins == nil || ins.Location.Index != 10 || ins.Text != "a\n\ta1\n\ta2\nb" {
185+
t.Fatalf("unexpected nested-list insert: %#v", ins)
186+
}
187+
bullets := got.Requests[2].CreateParagraphBullets
188+
if bullets == nil || bullets.Range.StartIndex != 10 || bullets.Range.EndIndex != 21 || bullets.BulletPreset != bulletPresetDisc {
189+
t.Fatalf("unexpected nested bullet request: %#v", bullets)
190+
}
191+
}
192+
153193
func TestDocsCellUpdate_ReplacesPlainCellWithInlineCodeStyle(t *testing.T) {
154194
origDocs := newDocsService
155195
t.Cleanup(func() { newDocsService = origDocs })

internal/cmd/docs_table_inserter.go

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64
103103
if cellIdx == 0 {
104104
continue
105105
}
106-
requests, insertedLen := buildTableCellRequests(cellContent, cellIdx, rowIdx == 0, tabID)
106+
requests, insertedLen, buildErr := buildTableCellRequests(cellContent, cellIdx, rowIdx == 0, tabID)
107+
if buildErr != nil {
108+
return tableEndIndex, buildErr
109+
}
107110
if len(requests) == 0 {
108111
continue
109112
}
@@ -134,25 +137,25 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64
134137
}
135138

136139
// buildTableCellRequests constructs the batch requests required to populate a
137-
// single table cell, expanding inline markdown (**bold**, *italic*, `code`,
138-
// [links]) into UpdateTextStyle requests on top of the inserted text. Header
139-
// cells additionally receive a whole-cell bold style. Returns the requests and
140-
// the UTF-16 length of the text that will be inserted so callers can keep
141-
// running cell indices in sync. If the cell content strips to an empty string
142-
// (e.g. content was only markers), returns (nil, 0).
143-
func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool, tabID string) ([]*docs.Request, int64) {
144-
styles, stripped := ParseInlineFormatting(cellContent)
145-
if stripped == "" {
146-
return nil, 0
140+
// single table cell, including block Markdown such as native nested lists.
141+
// Header cells additionally receive a whole-cell bold style. insertedLen is
142+
// the final UTF-16 growth after CreateParagraphBullets consumes nesting tabs.
143+
func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool, tabID string) ([]*docs.Request, int64, error) {
144+
formatRequests, text, insertedLen, err := buildMarkdownCellContent(cellContent, cellIdx, tabID)
145+
if err != nil {
146+
return nil, 0, err
147+
}
148+
if text == "" {
149+
return nil, 0, nil
147150
}
148151

149-
insertedLen := utf16Len(stripped)
150152
requests := []*docs.Request{{
151153
InsertText: &docs.InsertTextRequest{
152154
Location: &docs.Location{Index: cellIdx, TabId: tabID},
153-
Text: stripped,
155+
Text: text,
154156
},
155157
}}
158+
requests = append(requests, formatRequests...)
156159

157160
if isHeaderRow {
158161
requests = append(requests, &docs.Request{
@@ -168,13 +171,7 @@ func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool,
168171
})
169172
}
170173

171-
for _, style := range styles {
172-
if req := buildTextStyleRequest(style, cellIdx, tabID); req != nil {
173-
requests = append(requests, req)
174-
}
175-
}
176-
177-
return requests, insertedLen
174+
return requests, insertedLen, nil
178175
}
179176

180177
// getTableCellIndices extracts the start index for each cell in the table that

internal/cmd/docs_table_inserter_inline_test.go

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import (
1212
// pass used by paragraphs/headings.
1313

1414
func TestBuildTableCellRequests_AppliesInlineBold(t *testing.T) {
15-
reqs, inserted := buildTableCellRequests("**Alice**", 100, false, "")
15+
reqs, inserted, err := buildTableCellRequests("**Alice**", 100, false, "")
16+
if err != nil {
17+
t.Fatal(err)
18+
}
1619

1720
if inserted != utf16Len("Alice") {
1821
t.Fatalf("expected inserted len = utf16Len(\"Alice\") = %d, got %d", utf16Len("Alice"), inserted)
@@ -53,7 +56,10 @@ func TestBuildTableCellRequests_AppliesInlineItalicAndCode(t *testing.T) {
5356
}
5457
for _, tt := range cases {
5558
t.Run(tt.name, func(t *testing.T) {
56-
reqs, inserted := buildTableCellRequests(tt.cell, 50, false, "")
59+
reqs, inserted, err := buildTableCellRequests(tt.cell, 50, false, "")
60+
if err != nil {
61+
t.Fatal(err)
62+
}
5763
if inserted != utf16Len(tt.wantText) {
5864
t.Fatalf("inserted = %d, want %d", inserted, utf16Len(tt.wantText))
5965
}
@@ -86,7 +92,10 @@ func TestBuildTableCellRequests_TableBreaksPreserveInlineStyleRanges(t *testing.
8692
t.Fatalf("parseTableRow() = %#v, want one cell", rows)
8793
}
8894

89-
reqs, inserted := buildTableCellRequests(rows[0], 100, false, "")
95+
reqs, inserted, err := buildTableCellRequests(rows[0], 100, false, "")
96+
if err != nil {
97+
t.Fatal(err)
98+
}
9099
if inserted != utf16Len("Alice\nBob and <br>") {
91100
t.Fatalf("inserted = %d, want %d", inserted, utf16Len("Alice\nBob and <br>"))
92101
}
@@ -113,7 +122,10 @@ func TestBuildTableCellRequests_TableBreaksPreserveInlineStyleRanges(t *testing.
113122
}
114123

115124
func TestBuildTableCellRequests_HeaderRowAppliesBoldOverWholeCell(t *testing.T) {
116-
reqs, inserted := buildTableCellRequests("Field", 10, true, "")
125+
reqs, inserted, err := buildTableCellRequests("Field", 10, true, "")
126+
if err != nil {
127+
t.Fatal(err)
128+
}
117129

118130
if inserted != utf16Len("Field") {
119131
t.Fatalf("inserted = %d, want %d", inserted, utf16Len("Field"))
@@ -131,7 +143,10 @@ func TestBuildTableCellRequests_HeaderRowAppliesBoldOverWholeCell(t *testing.T)
131143
}
132144

133145
func TestBuildTableCellRequests_IncludesTabID(t *testing.T) {
134-
reqs, inserted := buildTableCellRequests("**Field**", 10, true, "t.second")
146+
reqs, inserted, err := buildTableCellRequests("**Field**", 10, true, "t.second")
147+
if err != nil {
148+
t.Fatal(err)
149+
}
135150
if inserted != utf16Len("Field") {
136151
t.Fatalf("inserted = %d, want %d", inserted, utf16Len("Field"))
137152
}
@@ -152,7 +167,10 @@ func TestBuildTableCellRequests_IncludesTabID(t *testing.T) {
152167
}
153168

154169
func TestBuildTableCellRequests_PlainTextNoStyleRequest(t *testing.T) {
155-
reqs, inserted := buildTableCellRequests("plain text", 1, false, "")
170+
reqs, inserted, err := buildTableCellRequests("plain text", 1, false, "")
171+
if err != nil {
172+
t.Fatal(err)
173+
}
156174
if inserted != utf16Len("plain text") {
157175
t.Fatalf("inserted = %d, want %d", inserted, utf16Len("plain text"))
158176
}
@@ -166,12 +184,43 @@ func TestBuildTableCellRequests_PlainTextNoStyleRequest(t *testing.T) {
166184

167185
func TestBuildTableCellRequests_EmptyAfterStrippingReturnsNothing(t *testing.T) {
168186
// A cell whose entire content is markers (e.g. "**") would strip to "".
169-
reqs, inserted := buildTableCellRequests("", 1, false, "")
187+
reqs, inserted, err := buildTableCellRequests("", 1, false, "")
188+
if err != nil {
189+
t.Fatal(err)
190+
}
170191
if len(reqs) != 0 || inserted != 0 {
171192
t.Fatalf("expected (nil, 0) for empty cell, got reqs=%#v inserted=%d", reqs, inserted)
172193
}
173194
}
174195

196+
func TestBuildTableCellRequests_PreservesNestedMarkdownLists(t *testing.T) {
197+
rows := parseTableRow("| nested | - a<br> - a1<br> - a2<br>- b |")
198+
if len(rows) != 2 {
199+
t.Fatalf("parseTableRow() = %#v, want two cells", rows)
200+
}
201+
202+
reqs, inserted, err := buildTableCellRequests(rows[1], 100, false, "t.second")
203+
if err != nil {
204+
t.Fatal(err)
205+
}
206+
if inserted != utf16Len("a\na1\na2\nb") {
207+
t.Fatalf("inserted = %d, want final nested-list length %d", inserted, utf16Len("a\na1\na2\nb"))
208+
}
209+
if got := reqs[0].InsertText; got == nil || got.Text != "a\n\ta1\n\ta2\nb" {
210+
t.Fatalf("InsertText = %#v, want nested list text", got)
211+
}
212+
if len(reqs) != 2 {
213+
t.Fatalf("requests = %d, want insert+bullets: %#v", len(reqs), reqs)
214+
}
215+
bullets := reqs[1].CreateParagraphBullets
216+
if bullets == nil || bullets.BulletPreset != bulletPresetDisc {
217+
t.Fatalf("missing native bullets: %#v", reqs[1])
218+
}
219+
if bullets.Range == nil || bullets.Range.StartIndex != 100 || bullets.Range.EndIndex != 111 || bullets.Range.TabId != "t.second" {
220+
t.Fatalf("bullet range = %#v, want [100,111] in t.second", bullets.Range)
221+
}
222+
}
223+
175224
func textOf(r *docs.Request) string {
176225
if r == nil || r.InsertText == nil {
177226
return ""

0 commit comments

Comments
 (0)