Skip to content

Commit ab9a331

Browse files
authored
feat(docs): add positional markdown insertion (#854)
Add formatted Markdown insertion to docs insert, preserving existing placement semantics and anchored-write revision control. Co-authored-by: Sebastian Roth <[email protected]>
1 parent d2e0bd7 commit ab9a331

6 files changed

Lines changed: 236 additions & 1 deletion

File tree

CHANGELOG.md

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

33
## 0.29.1 - Unreleased
44

5+
### Added
6+
7+
- Docs: add `insert --markdown` to convert markdown to Google Docs formatting and place the converted block at a position resolved by `--index`/`--at`/`--occurrence`, giving `insert` parity with the existing `update --markdown` and `write --markdown` paths. (#851, #854) — thanks @sebsnyk.
8+
59
## 0.29.0 - 2026-06-19
610

711
### Added

docs/commands/gog-docs-insert.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ gog docs (doc) insert <docId> [<content>] [flags]
3535
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
3636
| `--index` | `*int64` | | Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted. |
3737
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
38+
| `--markdown` | `bool` | | Convert markdown to Google Docs formatting before inserting |
3839
| `--match-case` | `bool` | | Use case-sensitive --at matching |
3940
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
4041
| `--occurrence` | `*int` | | Use the Nth --at match (1-based; required when --at is ambiguous) |

docs/docs-editing.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ Use `--occurrence N` when an anchor is ambiguous and `--match-case` when case
111111
must be exact. `docs comments locate` applies the same matching rules to a
112112
comment's quoted text and reports its tab plus UTF-16 range.
113113

114+
`insert` and `update` both accept `--markdown` to convert the content (headings,
115+
fenced code blocks, lists, tables, images) before placing it at the resolved
116+
position. `insert --markdown` adds the block without deleting anything; `update
117+
--markdown` with `--at`/`--replace-range` replaces the matched range.
118+
119+
```bash
120+
gog docs insert <docId> --markdown --at "## Section" --file block.md
121+
```
122+
114123
Command pages:
115124

116125
- [`gog docs find-range`](commands/gog-docs-find-range.md)

internal/cmd/docs_edit.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,7 @@ type DocsInsertCmd struct {
888888
Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"`
889889
MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"`
890890
File string `name:"file" short:"f" help:"Read content from file (use - for stdin)"`
891+
Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting before inserting"`
891892
Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"`
892893
TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"`
893894
Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"`
@@ -925,9 +926,13 @@ func (c *DocsInsertCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
925926
return tabErr
926927
}
927928
c.Tab = tab
929+
if c.Markdown && c.Batch != "" {
930+
return usage("--markdown cannot be combined with --batch")
931+
}
928932
dryRunPayload := map[string]any{
929933
"documentId": docID,
930934
"inserted": len(content),
935+
"markdown": c.Markdown,
931936
"tab": c.Tab,
932937
"batch": c.Batch,
933938
}
@@ -963,6 +968,10 @@ func (c *DocsInsertCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
963968
insertIndex := resolvedPlacement.Index
964969
c.Tab = resolvedPlacement.TabID
965970

971+
if c.Markdown {
972+
return c.runMarkdown(ctx, svc, docID, insertIndex, resolvedPlacement.RequiredRevisionID, content)
973+
}
974+
966975
batchReq := &docs.BatchUpdateDocumentRequest{
967976
Requests: []*docs.Request{docsedit.BuildInsertRequest(content, insertIndex, c.Tab)},
968977
}
@@ -992,6 +1001,80 @@ func (c *DocsInsertCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
9921001
return nil
9931002
}
9941003

1004+
// runMarkdown converts the supplied content from markdown to Google Docs
1005+
// formatting and inserts it at insertIndex. It reuses the same converter +
1006+
// insertion helper that backs `docs write --markdown` and the non-replacing
1007+
// branch of `docs update --markdown`, so headings, fenced code blocks, lists,
1008+
// tables and images render identically regardless of which command placed them.
1009+
func (c *DocsInsertCmd) runMarkdown(
1010+
ctx context.Context,
1011+
svc *docs.Service,
1012+
docID string,
1013+
insertIndex int64,
1014+
requiredRevisionID string,
1015+
content string,
1016+
) error {
1017+
markdown := prepareMarkdown(content)
1018+
insertResult, err := insertPreparedDocsMarkdownAtWithWriteControl(
1019+
ctx,
1020+
svc,
1021+
docID,
1022+
insertIndex,
1023+
markdown,
1024+
c.Tab,
1025+
true,
1026+
docsRequiredRevisionWriteControl(requiredRevisionID),
1027+
)
1028+
if err != nil {
1029+
if isDocsNotFound(err) {
1030+
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID)
1031+
}
1032+
return err
1033+
}
1034+
requestCount := insertResult.RequestCount
1035+
if markdownMayContainHeadingLinks(markdown.cleaned) {
1036+
explicitHeadingAnchors := docsmarkdown.ExplicitHeadingAnchors(markdown.cleaned)
1037+
rewritten, rewriteErr := rewriteMarkdownHeadingLinksInRange(
1038+
ctx,
1039+
svc,
1040+
docID,
1041+
c.Tab,
1042+
explicitHeadingAnchors,
1043+
insertResult.ContentStart,
1044+
insertResult.ContentEnd,
1045+
)
1046+
if rewriteErr != nil {
1047+
return fmt.Errorf("rewrite heading links: %w", rewriteErr)
1048+
}
1049+
requestCount += rewritten
1050+
}
1051+
1052+
if outfmt.IsJSON(ctx) {
1053+
payload := map[string]any{
1054+
"documentId": docID,
1055+
"inserted": insertResult.Inserted,
1056+
"requests": requestCount,
1057+
"atIndex": insertIndex,
1058+
"markdown": true,
1059+
}
1060+
if c.Tab != "" {
1061+
payload["tabId"] = c.Tab
1062+
}
1063+
return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload)
1064+
}
1065+
1066+
u := ui.FromContext(ctx)
1067+
u.Out().Linef("documentId\t%s", docID)
1068+
u.Out().Linef("inserted\t%d", insertResult.Inserted)
1069+
u.Out().Linef("requests\t%d", requestCount)
1070+
u.Out().Linef("atIndex\t%d", insertIndex)
1071+
u.Out().Linef("markdown\ttrue")
1072+
if c.Tab != "" {
1073+
u.Out().Linef("tabId\t%s", c.Tab)
1074+
}
1075+
return nil
1076+
}
1077+
9951078
type DocsDeleteCmd struct {
9961079
DocID string `arg:"" name:"docId" help:"Doc ID"`
9971080
Start *int64 `name:"start" help:"Start index (>= 1; required unless --at is set)"`
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"google.golang.org/api/docs/v1"
11+
)
12+
13+
// TestDocsInsertCmd_MarkdownAtIndex verifies that `docs insert --markdown
14+
// --index N` converts the markdown and places the converted block at the
15+
// resolved index: a body InsertText at N plus the converter's heading
16+
// paragraph-style request, rather than a single plain InsertText.
17+
func TestDocsInsertCmd_MarkdownAtIndex(t *testing.T) {
18+
t.Parallel()
19+
20+
var batchRequests [][]*docs.Request
21+
var getCalls int
22+
23+
docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+
switch {
25+
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"):
26+
getCalls++
27+
w.Header().Set("Content-Type", "application/json")
28+
_ = json.NewEncoder(w).Encode(docBodyWithEndIndex(42))
29+
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"):
30+
var req docs.BatchUpdateDocumentRequest
31+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
32+
t.Fatalf("decode: %v", err)
33+
}
34+
batchRequests = append(batchRequests, req.Requests)
35+
w.Header().Set("Content-Type", "application/json")
36+
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
37+
default:
38+
http.NotFound(w, r)
39+
}
40+
}))
41+
defer cleanup()
42+
43+
flags := &RootFlags{Account: "[email protected]"}
44+
ctx := withDocsTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), docSvc)
45+
46+
markdown := "## Heading\n\n```\ncode\n```"
47+
if err := runKong(t, &DocsInsertCmd{}, []string{"doc1", markdown, "--markdown", "--index", "7"}, ctx, flags); err != nil {
48+
t.Fatalf("insert --markdown: %v", err)
49+
}
50+
51+
// Explicit --index means no GET is needed to resolve placement, and the
52+
// markdown has no heading links so no rewrite GET is issued either.
53+
if getCalls != 0 {
54+
t.Fatalf("explicit --index with no heading links should not GET the doc, got %d", getCalls)
55+
}
56+
if len(batchRequests) != 1 {
57+
t.Fatalf("expected one batchUpdate, got %d", len(batchRequests))
58+
}
59+
60+
var insertAtIndex *docs.InsertTextRequest
61+
var sawHeadingStyle bool
62+
var fencedCodeStyle *docs.UpdateTextStyleRequest
63+
for _, req := range batchRequests[0] {
64+
if req.InsertText != nil && req.InsertText.Location != nil && req.InsertText.Location.Index == 7 {
65+
insertAtIndex = req.InsertText
66+
}
67+
if req.UpdateParagraphStyle != nil &&
68+
req.UpdateParagraphStyle.ParagraphStyle != nil &&
69+
strings.HasPrefix(req.UpdateParagraphStyle.ParagraphStyle.NamedStyleType, "HEADING") {
70+
sawHeadingStyle = true
71+
}
72+
if req.UpdateTextStyle != nil && strings.Contains(req.UpdateTextStyle.Fields, "weightedFontFamily") {
73+
fencedCodeStyle = req.UpdateTextStyle
74+
}
75+
}
76+
if insertAtIndex == nil {
77+
t.Fatalf("expected an InsertText at index 7, requests: %#v", batchRequests[0])
78+
}
79+
if !strings.Contains(insertAtIndex.Text, "Heading") {
80+
t.Fatalf("expected inserted text to contain the heading text, got %q", insertAtIndex.Text)
81+
}
82+
if !sawHeadingStyle {
83+
t.Fatalf("expected a HEADING paragraph-style request from the markdown converter, requests: %#v", batchRequests[0])
84+
}
85+
assertFencedCodeTextStyle(t, fencedCodeStyle)
86+
}
87+
88+
func TestDocsInsertCmd_MarkdownAtAnchorUsesRevisionControl(t *testing.T) {
89+
t.Parallel()
90+
91+
rec := &docsAtAnchorRecorder{}
92+
doc := docsFindRangeDoc(docsFindRangeParagraph(1, "Before anchor after\n"))
93+
doc.RevisionId = "rev-markdown-anchor"
94+
svc := setupDocsAtAnchorTestService(t, doc, rec)
95+
96+
flags := &RootFlags{Account: "[email protected]"}
97+
ctx := withDocsTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc)
98+
if err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "## Heading", "--markdown", "--at", "anchor"}, ctx, flags); err != nil {
99+
t.Fatalf("insert --markdown --at: %v", err)
100+
}
101+
if len(rec.batchRequests) != 1 {
102+
t.Fatalf("batch calls = %d, want 1", len(rec.batchRequests))
103+
}
104+
assertDocsAtAnchorWriteControl(t, rec, 0, "rev-markdown-anchor")
105+
}
106+
107+
// TestDocsInsertCmd_MarkdownRejectsBatch verifies the --markdown/--batch combo
108+
// is rejected up front (the markdown path issues its own batchUpdate calls and
109+
// cannot be queued into a persisted batch).
110+
func TestDocsInsertCmd_MarkdownRejectsBatch(t *testing.T) {
111+
t.Parallel()
112+
113+
docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114+
http.NotFound(w, r)
115+
}))
116+
defer cleanup()
117+
118+
flags := &RootFlags{Account: "[email protected]"}
119+
ctx := withDocsTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), docSvc)
120+
121+
err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "## H", "--markdown", "--batch", "b1"}, ctx, flags)
122+
if err == nil || !strings.Contains(err.Error(), "--markdown cannot be combined with --batch") {
123+
t.Fatalf("expected --markdown/--batch rejection, got %v", err)
124+
}
125+
}

internal/cmd/docs_mutation.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,19 @@ func insertPreparedDocsMarkdownAt(
284284
markdown preparedMarkdown,
285285
tabID string,
286286
stripHeadingAnchors bool,
287+
) (docsMarkdownInsertResult, error) {
288+
return insertPreparedDocsMarkdownAtWithWriteControl(ctx, svc, docID, insertIdx, markdown, tabID, stripHeadingAnchors, nil)
289+
}
290+
291+
func insertPreparedDocsMarkdownAtWithWriteControl(
292+
ctx context.Context,
293+
svc *docs.Service,
294+
docID string,
295+
insertIdx int64,
296+
markdown preparedMarkdown,
297+
tabID string,
298+
stripHeadingAnchors bool,
299+
writeControl *docs.WriteControl,
287300
) (docsMarkdownInsertResult, error) {
288301
elements := docsmarkdown.ParseMarkdown(markdown.cleaned)
289302
if stripHeadingAnchors {
@@ -321,7 +334,7 @@ func insertPreparedDocsMarkdownAt(
321334
})
322335
requests = append(requests, formattingRequests...)
323336

324-
requestCount, err := submitBatchedDocsRequests(ctx, svc, docID, requests, nil)
337+
requestCount, err := submitBatchedDocsRequests(ctx, svc, docID, requests, writeControl)
325338
if err != nil {
326339
return docsMarkdownInsertResult{}, fmt.Errorf("append (markdown): %w", err)
327340
}

0 commit comments

Comments
 (0)