Skip to content

Commit fda05f7

Browse files
committed
fix(docs): preserve tab markdown nested lists
1 parent 490a9cb commit fda05f7

8 files changed

Lines changed: 406 additions & 47 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
### Fixed
1212

13+
- Docs: preserve nested list levels when writing markdown into a specific tab with `docs write --replace --markdown --tab`. (#696)
14+
- Docs: fix `docs export --tab` tab resolution against the live Docs API field mask. (#696)
1315
- Docs: render GFM `~~strikethrough~~` spans in the local markdown writer used by `docs write --tab --markdown`. (#702)
1416
- 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.
1517
- 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.

internal/cmd/docs_formatter.go

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64, tabID s
3434
fmt.Printf("[DEBUG] Starting MarkdownToDocsRequests with %d elements\n", len(elements))
3535
}
3636

37-
for _, el := range elements {
37+
for i := 0; i < len(elements); i++ {
38+
el := elements[i]
3839
startOffset := charOffset
3940

4041
switch el.Type {
@@ -190,46 +191,84 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64, tabID s
190191
}
191192

192193
case MDListItem, MDNumberedList:
193-
// Parse inline formatting for list item content
194-
styles, strippedContent := ParseInlineFormatting(el.Content)
195-
196-
if debugMarkdown {
197-
fmt.Printf("[LIST] Content: %q -> stripped=%q styles=%d\n", el.Content, strippedContent, len(styles))
198-
}
199-
200-
// Emit the list item as a bare paragraph and then promote it to a
201-
// native Google Docs bullet/numbered list via CreateParagraphBullets.
202-
// Previously we inlined a literal "• " or "1. " prefix as text,
203-
// which left the paragraph with NORMAL_TEXT style and a glyph in
204-
// the text run instead of a proper BULLET paragraph style — see
205-
// #594.
206-
plainText.WriteString(strippedContent)
207-
plainText.WriteString("\n")
208-
charOffset += utf16Len(strippedContent + "\n")
209-
194+
blockEnd := startOffset
210195
bulletPreset := bulletPresetDisc
211196
if el.Type == MDNumberedList {
212197
bulletPreset = bulletPresetNumbered
213198
}
199+
blockType := el.Type
200+
var listPresetRequests []*docs.Request
201+
var listStyleRequests []*docs.Request
202+
203+
for ; i < len(elements); i++ {
204+
el = elements[i]
205+
if el.Type != MDListItem && el.Type != MDNumberedList {
206+
i--
207+
break
208+
}
209+
if el.Type != blockType && el.Level == 0 {
210+
i--
211+
break
212+
}
213+
214+
styles, strippedContent := ParseInlineFormatting(el.Content)
215+
leadingTabs := strings.Repeat("\t", el.Level)
216+
itemStart := charOffset
217+
itemEnd := itemStart + utf16Len(strippedContent+"\n")
218+
219+
if debugMarkdown {
220+
fmt.Printf("[LIST] Content: %q -> stripped=%q styles=%d\n", el.Content, strippedContent, len(styles))
221+
}
222+
223+
// Emit list items as bare paragraphs with leading tabs for
224+
// nesting, then promote the whole contiguous list block to a
225+
// native Google Docs list. Keeping the range whole is what
226+
// preserves Docs nesting levels; mixed child marker kinds get a
227+
// later preset override using post-tab-removal item ranges.
228+
// CreateParagraphBullets consumes those tabs, so inline styles
229+
// below use post-consumption itemStart offsets.
230+
listText := leadingTabs + strippedContent + "\n"
231+
plainText.WriteString(listText)
232+
blockEnd += utf16Len(listText)
233+
234+
if el.Type != blockType {
235+
itemPreset := bulletPresetDisc
236+
if el.Type == MDNumberedList {
237+
itemPreset = bulletPresetNumbered
238+
}
239+
listPresetRequests = append(listPresetRequests, &docs.Request{
240+
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
241+
Range: &docs.Range{
242+
StartIndex: itemStart,
243+
EndIndex: itemEnd,
244+
TabId: tabID,
245+
},
246+
BulletPreset: itemPreset,
247+
},
248+
})
249+
}
250+
251+
for _, style := range styles {
252+
textStyleReq := buildTextStyleRequest(style, itemStart, tabID)
253+
if textStyleReq != nil {
254+
listStyleRequests = append(listStyleRequests, textStyleReq)
255+
}
256+
}
257+
charOffset = itemEnd
258+
}
259+
214260
requests = append(requests, &docs.Request{
215261
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
216262
Range: &docs.Range{
217263
StartIndex: startOffset,
218-
EndIndex: charOffset,
264+
EndIndex: blockEnd,
219265
TabId: tabID,
220266
},
221267
BulletPreset: bulletPreset,
222268
},
223269
})
224-
225-
// Apply inline text styles (no prefix offset now that the bullet
226-
// glyph comes from the paragraph style rather than the text run).
227-
for _, style := range styles {
228-
textStyleReq := buildTextStyleRequest(style, startOffset, tabID)
229-
if textStyleReq != nil {
230-
requests = append(requests, textStyleReq)
231-
}
232-
}
270+
requests = append(requests, listPresetRequests...)
271+
requests = append(requests, listStyleRequests...)
233272

234273
case MDHorizontalRule:
235274
// Add horizontal rule as a separator line using ASCII dashes

internal/cmd/docs_formatter_test.go

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package cmd
33
import (
44
"strings"
55
"testing"
6+
7+
"google.golang.org/api/docs/v1"
68
)
79

810
func TestMarkdownToDocsRequests_BaseIndex(t *testing.T) {
@@ -75,6 +77,103 @@ func TestMarkdownToDocsRequests_Strikethrough(t *testing.T) {
7577
}
7678
}
7779

80+
func TestMarkdownToDocsRequests_NestedLists(t *testing.T) {
81+
elements := ParseMarkdown("- Parent\n - **Child**\n - Grandchild\n\n1. One\n 1. Nested one")
82+
requests, text, tables := MarkdownToDocsRequests(elements, 10, "t.second")
83+
84+
wantText := "Parent\n\tChild\n\t\tGrandchild\n\nOne\n\tNested one\n"
85+
if text != wantText {
86+
t.Fatalf("text = %q, want %q", text, wantText)
87+
}
88+
if len(tables) != 0 {
89+
t.Fatalf("unexpected tables: %d", len(tables))
90+
}
91+
92+
wantBullets := []struct {
93+
start int64
94+
end int64
95+
preset string
96+
}{
97+
{10, 37, bulletPresetDisc},
98+
{35, 51, bulletPresetNumbered},
99+
}
100+
var gotBullets []struct {
101+
start int64
102+
end int64
103+
preset string
104+
}
105+
var boldRange *docs.Range
106+
for _, req := range requests {
107+
if req.CreateParagraphBullets != nil {
108+
got := req.CreateParagraphBullets
109+
gotBullets = append(gotBullets, struct {
110+
start int64
111+
end int64
112+
preset string
113+
}{got.Range.StartIndex, got.Range.EndIndex, got.BulletPreset})
114+
}
115+
if req.UpdateTextStyle != nil && req.UpdateTextStyle.TextStyle != nil && req.UpdateTextStyle.TextStyle.Bold {
116+
boldRange = req.UpdateTextStyle.Range
117+
}
118+
}
119+
if len(gotBullets) != len(wantBullets) {
120+
t.Fatalf("bullet requests = %#v, want %#v", gotBullets, wantBullets)
121+
}
122+
for i, want := range wantBullets {
123+
if got := gotBullets[i]; got != want {
124+
t.Fatalf("bullet %d = %#v, want %#v", i, got, want)
125+
}
126+
}
127+
if boldRange == nil || boldRange.StartIndex != 17 || boldRange.EndIndex != 22 || boldRange.TabId != "t.second" {
128+
t.Fatalf("unexpected bold range after nested bullet tab removal: %#v", boldRange)
129+
}
130+
}
131+
132+
func TestMarkdownToDocsRequests_MixedListChildrenStayNested(t *testing.T) {
133+
elements := ParseMarkdown("1. Parent\n - Bullet child\n 1. Number child\n2. Sibling")
134+
requests, text, tables := MarkdownToDocsRequests(elements, 1, "t.second")
135+
136+
wantText := "Parent\n\tBullet child\n\tNumber child\nSibling\n"
137+
if text != wantText {
138+
t.Fatalf("text = %q, want %q", text, wantText)
139+
}
140+
if len(tables) != 0 {
141+
t.Fatalf("unexpected tables: %d", len(tables))
142+
}
143+
144+
wantBullets := []struct {
145+
start int64
146+
end int64
147+
preset string
148+
}{
149+
{1, 44, bulletPresetNumbered},
150+
{8, 21, bulletPresetDisc},
151+
}
152+
var gotBullets []struct {
153+
start int64
154+
end int64
155+
preset string
156+
}
157+
for _, req := range requests {
158+
if req.CreateParagraphBullets != nil {
159+
got := req.CreateParagraphBullets
160+
gotBullets = append(gotBullets, struct {
161+
start int64
162+
end int64
163+
preset string
164+
}{got.Range.StartIndex, got.Range.EndIndex, got.BulletPreset})
165+
}
166+
}
167+
if len(gotBullets) != len(wantBullets) {
168+
t.Fatalf("bullet requests = %#v, want %#v", gotBullets, wantBullets)
169+
}
170+
for i, want := range wantBullets {
171+
if got := gotBullets[i]; got != want {
172+
t.Fatalf("bullet %d = %#v, want %#v", i, got, want)
173+
}
174+
}
175+
}
176+
78177
// TestMarkdownToDocsRequests_AppendBulletsAndCode is a regression test for
79178
// #594. The append path used to inline literal "• " glyphs for bullet lists
80179
// (leaving paragraphs as NORMAL_TEXT) and split fenced code blocks into one
@@ -126,8 +225,7 @@ func TestMarkdownToDocsRequests_AppendBulletsAndCode(t *testing.T) {
126225
}
127226

128227
// We expect at least:
129-
// - 2 CreateParagraphBullets requests for the two bullet items
130-
// (NB: they may be one per item; we count >= 2)
228+
// - 1 CreateParagraphBullets request for the contiguous bullet block
131229
// - 1 CreateParagraphBullets for the numbered item
132230
// - 1 UpdateParagraphStyle with paragraph-level shading covering the
133231
// code block
@@ -165,8 +263,8 @@ func TestMarkdownToDocsRequests_AppendBulletsAndCode(t *testing.T) {
165263
}
166264
}
167265

168-
if bulletDisc < 2 {
169-
t.Errorf("expected at least 2 BULLET_DISC_CIRCLE_SQUARE CreateParagraphBullets, got %d", bulletDisc)
266+
if bulletDisc < 1 {
267+
t.Errorf("expected at least 1 BULLET_DISC_CIRCLE_SQUARE CreateParagraphBullets, got %d", bulletDisc)
170268
}
171269
if bulletNumbered < 1 {
172270
t.Errorf("expected at least 1 %s CreateParagraphBullets, got %d", bulletPresetNumbered, bulletNumbered)

0 commit comments

Comments
 (0)