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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Docs: preserve nested list levels when writing markdown into a specific tab with `docs write --replace --markdown --tab`. (#696)
- Docs: fix `docs export --tab` tab resolution against the live Docs API field mask. (#696)
- Docs: render GFM `~~strikethrough~~` spans in the local markdown writer used by `docs write --tab --markdown`. (#702)
- 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.
Expand Down
95 changes: 67 additions & 28 deletions internal/cmd/docs_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64, tabID s
fmt.Printf("[DEBUG] Starting MarkdownToDocsRequests with %d elements\n", len(elements))
}

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

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

case MDListItem, MDNumberedList:
// Parse inline formatting for list item content
styles, strippedContent := ParseInlineFormatting(el.Content)

if debugMarkdown {
fmt.Printf("[LIST] Content: %q -> stripped=%q styles=%d\n", el.Content, strippedContent, len(styles))
}

// Emit the list item as a bare paragraph and then promote it to a
// native Google Docs bullet/numbered list via CreateParagraphBullets.
// Previously we inlined a literal "• " or "1. " prefix as text,
// which left the paragraph with NORMAL_TEXT style and a glyph in
// the text run instead of a proper BULLET paragraph style — see
// #594.
plainText.WriteString(strippedContent)
plainText.WriteString("\n")
charOffset += utf16Len(strippedContent + "\n")

blockEnd := startOffset
bulletPreset := bulletPresetDisc
if el.Type == MDNumberedList {
bulletPreset = bulletPresetNumbered
}
blockType := el.Type
var listPresetRequests []*docs.Request
var listStyleRequests []*docs.Request

for ; i < len(elements); i++ {
el = elements[i]
if el.Type != MDListItem && el.Type != MDNumberedList {
i--
break
}
if el.Type != blockType && el.Level == 0 {
i--
break
}

styles, strippedContent := ParseInlineFormatting(el.Content)
leadingTabs := strings.Repeat("\t", el.Level)
itemStart := charOffset
itemEnd := itemStart + utf16Len(strippedContent+"\n")

if debugMarkdown {
fmt.Printf("[LIST] Content: %q -> stripped=%q styles=%d\n", el.Content, strippedContent, len(styles))
}

// Emit list items as bare paragraphs with leading tabs for
// nesting, then promote the whole contiguous list block to a
// native Google Docs list. Keeping the range whole is what
// preserves Docs nesting levels; mixed child marker kinds get a
// later preset override using post-tab-removal item ranges.
// CreateParagraphBullets consumes those tabs, so inline styles
// below use post-consumption itemStart offsets.
listText := leadingTabs + strippedContent + "\n"
plainText.WriteString(listText)
blockEnd += utf16Len(listText)

if el.Type != blockType {
itemPreset := bulletPresetDisc
if el.Type == MDNumberedList {
itemPreset = bulletPresetNumbered
}
listPresetRequests = append(listPresetRequests, &docs.Request{
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
Range: &docs.Range{
StartIndex: itemStart,
EndIndex: itemEnd,
TabId: tabID,
},
BulletPreset: itemPreset,
},
})
}

for _, style := range styles {
textStyleReq := buildTextStyleRequest(style, itemStart, tabID)
if textStyleReq != nil {
listStyleRequests = append(listStyleRequests, textStyleReq)
}
}
charOffset = itemEnd
}

requests = append(requests, &docs.Request{
CreateParagraphBullets: &docs.CreateParagraphBulletsRequest{
Range: &docs.Range{
StartIndex: startOffset,
EndIndex: charOffset,
EndIndex: blockEnd,
TabId: tabID,
},
BulletPreset: bulletPreset,
},
})

// Apply inline text styles (no prefix offset now that the bullet
// glyph comes from the paragraph style rather than the text run).
for _, style := range styles {
textStyleReq := buildTextStyleRequest(style, startOffset, tabID)
if textStyleReq != nil {
requests = append(requests, textStyleReq)
}
}
requests = append(requests, listPresetRequests...)
requests = append(requests, listStyleRequests...)

case MDHorizontalRule:
// Add horizontal rule as a separator line using ASCII dashes
Expand Down
106 changes: 102 additions & 4 deletions internal/cmd/docs_formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package cmd
import (
"strings"
"testing"

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

func TestMarkdownToDocsRequests_BaseIndex(t *testing.T) {
Expand Down Expand Up @@ -75,6 +77,103 @@ func TestMarkdownToDocsRequests_Strikethrough(t *testing.T) {
}
}

func TestMarkdownToDocsRequests_NestedLists(t *testing.T) {
elements := ParseMarkdown("- Parent\n - **Child**\n - Grandchild\n\n1. One\n 1. Nested one")
requests, text, tables := MarkdownToDocsRequests(elements, 10, "t.second")

wantText := "Parent\n\tChild\n\t\tGrandchild\n\nOne\n\tNested one\n"
if text != wantText {
t.Fatalf("text = %q, want %q", text, wantText)
}
if len(tables) != 0 {
t.Fatalf("unexpected tables: %d", len(tables))
}

wantBullets := []struct {
start int64
end int64
preset string
}{
{10, 37, bulletPresetDisc},
{35, 51, bulletPresetNumbered},
}
var gotBullets []struct {
start int64
end int64
preset string
}
var boldRange *docs.Range
for _, req := range requests {
if req.CreateParagraphBullets != nil {
got := req.CreateParagraphBullets
gotBullets = append(gotBullets, struct {
start int64
end int64
preset string
}{got.Range.StartIndex, got.Range.EndIndex, got.BulletPreset})
}
if req.UpdateTextStyle != nil && req.UpdateTextStyle.TextStyle != nil && req.UpdateTextStyle.TextStyle.Bold {
boldRange = req.UpdateTextStyle.Range
}
}
if len(gotBullets) != len(wantBullets) {
t.Fatalf("bullet requests = %#v, want %#v", gotBullets, wantBullets)
}
for i, want := range wantBullets {
if got := gotBullets[i]; got != want {
t.Fatalf("bullet %d = %#v, want %#v", i, got, want)
}
}
if boldRange == nil || boldRange.StartIndex != 17 || boldRange.EndIndex != 22 || boldRange.TabId != "t.second" {
t.Fatalf("unexpected bold range after nested bullet tab removal: %#v", boldRange)
}
}

func TestMarkdownToDocsRequests_MixedListChildrenStayNested(t *testing.T) {
elements := ParseMarkdown("1. Parent\n - Bullet child\n 1. Number child\n2. Sibling")
requests, text, tables := MarkdownToDocsRequests(elements, 1, "t.second")

wantText := "Parent\n\tBullet child\n\tNumber child\nSibling\n"
if text != wantText {
t.Fatalf("text = %q, want %q", text, wantText)
}
if len(tables) != 0 {
t.Fatalf("unexpected tables: %d", len(tables))
}

wantBullets := []struct {
start int64
end int64
preset string
}{
{1, 44, bulletPresetNumbered},
{8, 21, bulletPresetDisc},
}
var gotBullets []struct {
start int64
end int64
preset string
}
for _, req := range requests {
if req.CreateParagraphBullets != nil {
got := req.CreateParagraphBullets
gotBullets = append(gotBullets, struct {
start int64
end int64
preset string
}{got.Range.StartIndex, got.Range.EndIndex, got.BulletPreset})
}
}
if len(gotBullets) != len(wantBullets) {
t.Fatalf("bullet requests = %#v, want %#v", gotBullets, wantBullets)
}
for i, want := range wantBullets {
if got := gotBullets[i]; got != want {
t.Fatalf("bullet %d = %#v, want %#v", i, got, want)
}
}
}

// TestMarkdownToDocsRequests_AppendBulletsAndCode is a regression test for
// #594. The append path used to inline literal "• " glyphs for bullet lists
// (leaving paragraphs as NORMAL_TEXT) and split fenced code blocks into one
Expand Down Expand Up @@ -126,8 +225,7 @@ func TestMarkdownToDocsRequests_AppendBulletsAndCode(t *testing.T) {
}

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

if bulletDisc < 2 {
t.Errorf("expected at least 2 BULLET_DISC_CIRCLE_SQUARE CreateParagraphBullets, got %d", bulletDisc)
if bulletDisc < 1 {
t.Errorf("expected at least 1 BULLET_DISC_CIRCLE_SQUARE CreateParagraphBullets, got %d", bulletDisc)
}
if bulletNumbered < 1 {
t.Errorf("expected at least 1 %s CreateParagraphBullets, got %d", bulletPresetNumbered, bulletNumbered)
Expand Down
Loading
Loading