From fda05f7d076b68404d1a62ce931a39660b20b8ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 07:52:44 +0100 Subject: [PATCH] fix(docs): preserve tab markdown nested lists --- CHANGELOG.md | 2 + internal/cmd/docs_formatter.go | 95 ++++++++++++----- internal/cmd/docs_formatter_test.go | 106 ++++++++++++++++++- internal/cmd/docs_markdown.go | 89 +++++++++++++--- internal/cmd/docs_markdown_test.go | 77 ++++++++++++++ internal/cmd/docs_tab_export.go | 10 +- internal/cmd/docs_tab_export_test.go | 6 ++ internal/cmd/docs_write_markdown_tab_test.go | 68 ++++++++++++ 8 files changed, 406 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fe5624..c67a6c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/internal/cmd/docs_formatter.go b/internal/cmd/docs_formatter.go index 443c02ac..3dd1a3b7 100644 --- a/internal/cmd/docs_formatter.go +++ b/internal/cmd/docs_formatter.go @@ -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 { @@ -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 diff --git a/internal/cmd/docs_formatter_test.go b/internal/cmd/docs_formatter_test.go index d0720cb3..5e78f0c4 100644 --- a/internal/cmd/docs_formatter_test.go +++ b/internal/cmd/docs_formatter_test.go @@ -3,6 +3,8 @@ package cmd import ( "strings" "testing" + + "google.golang.org/api/docs/v1" ) func TestMarkdownToDocsRequests_BaseIndex(t *testing.T) { @@ -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 @@ -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 @@ -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) diff --git a/internal/cmd/docs_markdown.go b/internal/cmd/docs_markdown.go index 4905f01a..51b38a66 100644 --- a/internal/cmd/docs_markdown.go +++ b/internal/cmd/docs_markdown.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "regexp" + "sort" "strings" "unicode" "unicode/utf16" @@ -76,12 +77,16 @@ func ParseMarkdown(text string) []MarkdownElement { inCodeBlock := false var codeBlockContent strings.Builder + var listIndents []int + listActive := false for i := 0; i < len(lines); i++ { line := lines[i] // Handle code blocks if strings.HasPrefix(line, "```") { + listIndents = nil + listActive = false if inCodeBlock { // End code block elements = append(elements, MarkdownElement{ @@ -107,6 +112,8 @@ func ParseMarkdown(text string) []MarkdownElement { // Empty line if strings.TrimSpace(line) == "" { + listIndents = nil + listActive = false if len(elements) > 0 && elements[len(elements)-1].Type != MDEmptyLine { elements = append(elements, MarkdownElement{Type: MDEmptyLine}) } @@ -115,6 +122,8 @@ func ParseMarkdown(text string) []MarkdownElement { // Horizontal rule if isHorizontalRule(line) { + listIndents = nil + listActive = false elements = append(elements, MarkdownElement{ Type: MDHorizontalRule, }) @@ -123,6 +132,8 @@ func ParseMarkdown(text string) []MarkdownElement { // Headings if headingLevel, content := parseHeading(line); headingLevel > 0 { + listIndents = nil + listActive = false headingType := MDHeading1 switch headingLevel { case 1: @@ -147,6 +158,8 @@ func ParseMarkdown(text string) []MarkdownElement { // Blockquote if strings.HasPrefix(line, "> ") { + listIndents = nil + listActive = false content := strings.TrimPrefix(line, "> ") if debugMarkdown { fmt.Printf("[PARSE] Blockquote detected: %q -> %q\n", line, content) @@ -158,22 +171,18 @@ func ParseMarkdown(text string) []MarkdownElement { continue } - // Numbered list - if match := regexp.MustCompile(`^(\d+)\.\s+(.+)`).FindStringSubmatch(line); match != nil { - elements = append(elements, MarkdownElement{ - Type: MDNumberedList, - Content: match[2], - }) - continue - } - - // Bullet list - if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { - content := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ") + // Lists, including tab-scoped markdown nesting. The Docs API derives + // nesting from leading tabs after CreateParagraphBullets is applied. + if listType, content, indent, ok := parseMarkdownListItem(line); ok && (indent == 0 || listActive) { + if indent == 0 { + listIndents = nil + } elements = append(elements, MarkdownElement{ - Type: MDListItem, + Type: listType, Content: content, + Level: markdownListLevel(indent, &listIndents), }) + listActive = true continue } @@ -196,6 +205,8 @@ func ParseMarkdown(text string) []MarkdownElement { Type: MDTable, TableCells: tableCells, }) + listIndents = nil + listActive = false // Skip all table lines i += countMarkdownTableLines(lines[i:]) - 1 continue @@ -203,6 +214,8 @@ func ParseMarkdown(text string) []MarkdownElement { } // Regular paragraph + listIndents = nil + listActive = false elements = append(elements, MarkdownElement{ Type: MDParagraph, Content: line, @@ -216,6 +229,56 @@ func ParseMarkdown(text string) []MarkdownElement { return elements } +var markdownNumberedListRE = regexp.MustCompile(`^(\d+)\.\s+(.+)`) + +func parseMarkdownListItem(line string) (MarkdownElementType, string, int, bool) { + indent, rest := markdownListIndentColumns(line) + if match := markdownNumberedListRE.FindStringSubmatch(rest); match != nil { + return MDNumberedList, match[2], indent, true + } + if strings.HasPrefix(rest, "- ") || strings.HasPrefix(rest, "* ") { + return MDListItem, rest[2:], indent, true + } + return MDText, "", 0, false +} + +func markdownListIndentColumns(line string) (int, string) { + column := 0 + i := 0 + for i < len(line) { + switch line[i] { + case ' ': + column++ + i++ + case '\t': + column += 4 - column%4 + i++ + default: + return column, line[i:] + } + } + return column, "" +} + +func markdownListLevel(indent int, indents *[]int) int { + if indent <= 0 { + return 0 + } + for i, seen := range *indents { + if seen == indent { + return i + 1 + } + } + *indents = append(*indents, indent) + sort.Ints(*indents) + for i, seen := range *indents { + if seen == indent { + return i + 1 + } + } + return len(*indents) +} + // isTableSeparator checks if a line is a markdown table separator (|---|---|) func isTableSeparator(line string) bool { trimmed := strings.TrimSpace(line) diff --git a/internal/cmd/docs_markdown_test.go b/internal/cmd/docs_markdown_test.go index c52d0ae0..cd737a0b 100644 --- a/internal/cmd/docs_markdown_test.go +++ b/internal/cmd/docs_markdown_test.go @@ -71,6 +71,83 @@ func TestParseMarkdown(t *testing.T) { } } +func TestParseMarkdown_NestedLists(t *testing.T) { + result := ParseMarkdown("- Parent\n - Child\n - Grandchild\n\t- Tab sibling\n1. One\n 1. Nested one") + if len(result) != 6 { + t.Fatalf("ParseMarkdown() got %d elements, want 6: %#v", len(result), result) + } + + want := []struct { + typ MarkdownElementType + content string + level int + }{ + {MDListItem, "Parent", 0}, + {MDListItem, "Child", 1}, + {MDListItem, "Grandchild", 2}, + {MDListItem, "Tab sibling", 2}, + {MDNumberedList, "One", 0}, + {MDNumberedList, "Nested one", 1}, + } + for i, w := range want { + if got := result[i]; got.Type != w.typ || got.Content != w.content || got.Level != w.level { + t.Fatalf("element %d = {type:%v content:%q level:%d}, want {type:%v content:%q level:%d}", + i, got.Type, got.Content, got.Level, w.typ, w.content, w.level) + } + } +} + +func TestParseMarkdown_NestedListsFourSpaceBlock(t *testing.T) { + result := ParseMarkdown("- Two-space parent\n - Two-space child\n\n- Four-space parent\n - Four-space child\n - Four-space grandchild") + if len(result) != 6 { + t.Fatalf("ParseMarkdown() got %d elements, want 6: %#v", len(result), result) + } + + want := []struct { + typ MarkdownElementType + content string + level int + }{ + {MDListItem, "Two-space parent", 0}, + {MDListItem, "Two-space child", 1}, + {MDEmptyLine, "", 0}, + {MDListItem, "Four-space parent", 0}, + {MDListItem, "Four-space child", 1}, + {MDListItem, "Four-space grandchild", 2}, + } + for i, w := range want { + if got := result[i]; got.Type != w.typ || got.Content != w.content || got.Level != w.level { + t.Fatalf("element %d = {type:%v content:%q level:%d}, want {type:%v content:%q level:%d}", + i, got.Type, got.Content, got.Level, w.typ, w.content, w.level) + } + } +} + +func TestParseMarkdown_IndentedListMarkerWithoutParent(t *testing.T) { + result := ParseMarkdown(" - keep literal") + if len(result) != 1 { + t.Fatalf("ParseMarkdown() got %d elements, want 1: %#v", len(result), result) + } + got := result[0] + if got.Type != MDParagraph || got.Content != " - keep literal" { + t.Fatalf("element = {type:%v content:%q}, want paragraph with literal text", got.Type, got.Content) + } +} + +func TestParseMarkdown_TopLevelListResetsNestedIndentStack(t *testing.T) { + result := ParseMarkdown("- A\n - B\n - C\n1. D\n 1. E") + if len(result) != 5 { + t.Fatalf("ParseMarkdown() got %d elements, want 5: %#v", len(result), result) + } + + wantLevels := []int{0, 1, 2, 0, 1} + for i, want := range wantLevels { + if got := result[i].Level; got != want { + t.Fatalf("element %d level = %d, want %d (%#v)", i, got, want, result[i]) + } + } +} + func TestParseInlineFormatting(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/docs_tab_export.go b/internal/cmd/docs_tab_export.go index 54e6224f..63894edf 100644 --- a/internal/cmd/docs_tab_export.go +++ b/internal/cmd/docs_tab_export.go @@ -25,7 +25,12 @@ import ( // UNSTABLE: docsTabExportBaseURL is the base for the undocumented per-tab // export endpoint. If Google changes or removes it, callers will see an HTTP // 302 redirect to a login page or an HTTP 404. -const docsTabExportBaseURL = "https://docs.google.com/document/d" +const ( + docsTabExportBaseURL = "https://docs.google.com/document/d" + docsTabExportTabFields = "tabs(tabProperties(tabId,title,index)," + + "childTabs(tabProperties(tabId,title,index)," + + "childTabs(tabProperties(tabId,title,index))))" +) // maxRedirects matches net/http.defaultCheckRedirect (10 hops). const maxRedirects = 10 @@ -106,7 +111,8 @@ func docsTabExportURL(docID, format, tabID string) string { func resolveTabID(ctx context.Context, docsSvc *docs.Service, docID, tabQuery string) (string, error) { doc, err := docsSvc.Documents.Get(docID). - Fields("tabs(tabProperties(tabId,title,index),childTabs)"). + IncludeTabsContent(true). + Fields(docsTabExportTabFields). Context(ctx). Do() if err != nil { diff --git a/internal/cmd/docs_tab_export_test.go b/internal/cmd/docs_tab_export_test.go index ba5ccd2e..c1419f60 100644 --- a/internal/cmd/docs_tab_export_test.go +++ b/internal/cmd/docs_tab_export_test.go @@ -170,6 +170,12 @@ func TestSanitizeFilenameComponent(t *testing.T) { func TestResolveTabID(t *testing.T) { docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("includeTabsContent"); got != "true" { + t.Fatalf("includeTabsContent = %q, want true", got) + } + if got := r.URL.Query().Get("fields"); got != docsTabExportTabFields { + t.Fatalf("fields = %q, want %q", got, docsTabExportTabFields) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(tabExportDocResponse()) })) diff --git a/internal/cmd/docs_write_markdown_tab_test.go b/internal/cmd/docs_write_markdown_tab_test.go index 029bd227..6d411f1c 100644 --- a/internal/cmd/docs_write_markdown_tab_test.go +++ b/internal/cmd/docs_write_markdown_tab_test.go @@ -125,6 +125,74 @@ func TestDocsWrite_MarkdownReplaceWithTab(t *testing.T) { } } +func TestDocsWrite_MarkdownReplaceWithTab_NestedLists(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var batchRequests [][]*docs.Request + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tabsDocWithEndIndex()) + return + case r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode batch request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer cleanup() + + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + newDriveService = func(context.Context, string) (*drive.Service, error) { + t.Fatal("markdown replace with --tab must not use the Drive converter") + return nil, errors.New("unexpected Drive service call") + } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + + markdown := "- Parent\n - Child\n - Grandchild\n" + if err := runKong(t, &DocsWriteCmd{}, []string{ + "doc1", "--text=" + markdown, "--replace", "--markdown", "--tab", "Second", + }, ctx, flags); err != nil { + t.Fatalf("markdown replace with nested tab list: %v", err) + } + if len(batchRequests) != 2 { + t.Fatalf("expected 2 batch requests (delete + insert), got %d", len(batchRequests)) + } + + insertReqs := batchRequests[1] + if len(insertReqs) != 2 { + t.Fatalf("expected insert plus 1 list-block bullet request, got %#v", insertReqs) + } + if got := insertReqs[0].InsertText; got == nil || got.Text != "Parent\n\tChild\n\t\tGrandchild\n" { + t.Fatalf("unexpected inserted text: %#v", got) + } + got := insertReqs[1].CreateParagraphBullets + if got == nil { + t.Fatalf("request 1 missing CreateParagraphBullets: %#v", insertReqs[1]) + } + if got.Range.TabId != "t.second" || got.Range.StartIndex != 1 || got.Range.EndIndex != 28 { + t.Fatalf("bullet range = %#v, want tab t.second [1,28)", got.Range) + } +} + // TestDocsWrite_MarkdownReplaceWithTab_EmptyTab verifies that when the // targeted tab is already empty (endIndex == 1) the DeleteContentRange step // is skipped — the Docs API rejects a delete range where end <= start.