From a8f8e863502ea679666a1dda5b0c7a3d21a0b27d Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:21:09 +0000 Subject: [PATCH 1/2] fix(docs): expose heading IDs in enumerator JSON --- internal/cmd/docs_enumerators.go | 16 ++++++-- internal/cmd/docs_enumerators_test.go | 57 ++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/internal/cmd/docs_enumerators.go b/internal/cmd/docs_enumerators.go index c55214e0e..00239e0ad 100644 --- a/internal/cmd/docs_enumerators.go +++ b/internal/cmd/docs_enumerators.go @@ -74,6 +74,7 @@ type docsParagraphListItem struct { StartIndex int64 `json:"startIndex"` EndIndex int64 `json:"endIndex"` Style string `json:"style"` + HeadingID string `json:"headingId,omitempty"` Text string `json:"text"` } @@ -82,6 +83,7 @@ type docsParagraphInspectItem struct { StartIndex int64 `json:"startIndex"` EndIndex int64 `json:"endIndex"` Style string `json:"style"` + HeadingID string `json:"headingId,omitempty"` Text string `json:"text"` IsEmpty bool `json:"isEmpty"` Runs []docsParagraphRun `json:"runs"` @@ -212,6 +214,7 @@ func (c *DocsHeadingsListCmd) Run(ctx context.Context, flags *RootFlags) error { StartIndex: paragraph.StartIndex, EndIndex: paragraph.EndIndex, Style: paragraph.Style, + HeadingID: paragraph.HeadingID, Text: paragraph.Text, }) } @@ -236,6 +239,7 @@ func (c *DocsParagraphsListCmd) Run(ctx context.Context, flags *RootFlags) error StartIndex: paragraph.StartIndex, EndIndex: paragraph.EndIndex, Style: paragraph.Style, + HeadingID: paragraph.HeadingID, Text: paragraph.Text, } items = append(items, item) @@ -244,6 +248,7 @@ func (c *DocsParagraphsListCmd) Run(ctx context.Context, flags *RootFlags) error StartIndex: item.StartIndex, EndIndex: item.EndIndex, Style: item.Style, + HeadingID: item.HeadingID, Text: item.Text, IsEmpty: paragraph.IsEmpty, Runs: paragraph.Runs, @@ -488,6 +493,7 @@ type docsEnumeratedParagraph struct { StartIndex int64 EndIndex int64 Style string + HeadingID string Text string IsEmpty bool Runs []docsParagraphRun @@ -506,15 +512,19 @@ func enumerateDocsParagraphs(doc *docs.Document) []docsEnumeratedParagraph { } if element.Paragraph != nil { style := docsNamedStyleNormalText - if element.Paragraph.ParagraphStyle != nil && - element.Paragraph.ParagraphStyle.NamedStyleType != "" { - style = element.Paragraph.ParagraphStyle.NamedStyleType + headingID := "" + if paragraphStyle := element.Paragraph.ParagraphStyle; paragraphStyle != nil { + if paragraphStyle.NamedStyleType != "" { + style = paragraphStyle.NamedStyleType + } + headingID = paragraphStyle.HeadingId } isEmpty, runs := inspectDocsParagraph(element.Paragraph) paragraphs = append(paragraphs, docsEnumeratedParagraph{ StartIndex: element.StartIndex, EndIndex: element.EndIndex, Style: style, + HeadingID: headingID, Text: paragraphText(element.Paragraph), IsEmpty: isEmpty, Runs: runs, diff --git a/internal/cmd/docs_enumerators_test.go b/internal/cmd/docs_enumerators_test.go index 11a83a5e8..d101164d7 100644 --- a/internal/cmd/docs_enumerators_test.go +++ b/internal/cmd/docs_enumerators_test.go @@ -203,6 +203,53 @@ func TestDocsHeadingsAndParagraphsFilters(t *testing.T) { } } +func TestDocsHeadingsAndParagraphsJSONIncludeHeadingID(t *testing.T) { + t.Parallel() + + response := map[string]any{ + "documentId": "doc1", + "body": map[string]any{"content": []any{ + rawStyledParagraphWithHeadingID(1, 12, "HEADING_1", "h.section", "Section\n"), + rawStyledParagraph(12, 20, "NORMAL_TEXT", "Body\n"), + }}, + } + srv := newDocsRawTestServer(t, 0, response) + defer srv.Close() + svc := newMockDocsService(t, srv) + + var output bytes.Buffer + ctx := withDocsTestService(newCmdRuntimeJSONOutputContext(t, &output, io.Discard), svc) + if err := runKong(t, &DocsHeadingsListCmd{}, []string{"doc1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("headings run: %v", err) + } + var headings struct { + Headings []docsParagraphListItem `json:"headings"` + } + if err := json.Unmarshal(output.Bytes(), &headings); err != nil { + t.Fatalf("unmarshal headings: %v\n%s", err, output.String()) + } + if len(headings.Headings) != 1 || headings.Headings[0].HeadingID != "h.section" { + t.Fatalf("headings = %#v", headings.Headings) + } + + output.Reset() + if err := runKong(t, &DocsParagraphsListCmd{}, []string{"doc1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("paragraphs run: %v", err) + } + var paragraphs struct { + Paragraphs []docsParagraphInspectItem `json:"paragraphs"` + } + if err := json.Unmarshal(output.Bytes(), ¶graphs); err != nil { + t.Fatalf("unmarshal paragraphs: %v\n%s", err, output.String()) + } + if len(paragraphs.Paragraphs) != 2 || paragraphs.Paragraphs[0].HeadingID != "h.section" { + t.Fatalf("paragraphs = %#v", paragraphs.Paragraphs) + } + if paragraphs.Paragraphs[1].HeadingID != "" { + t.Fatalf("normal paragraph headingId = %q, want empty", paragraphs.Paragraphs[1].HeadingID) + } +} + func TestDocsParagraphsJSONIncludesRunsAndEmptiness(t *testing.T) { t.Parallel() @@ -436,11 +483,19 @@ func rawTableCell(text string) map[string]any { } func rawStyledParagraph(start, end int64, style, text string) map[string]any { + return rawStyledParagraphWithHeadingID(start, end, style, "", text) +} + +func rawStyledParagraphWithHeadingID(start, end int64, style, headingID, text string) map[string]any { + paragraphStyle := map[string]any{"namedStyleType": style} + if headingID != "" { + paragraphStyle["headingId"] = headingID + } return map[string]any{ "startIndex": start, "endIndex": end, "paragraph": map[string]any{ - "paragraphStyle": map[string]any{"namedStyleType": style}, + "paragraphStyle": paragraphStyle, "elements": []any{ map[string]any{ "startIndex": start, From c13be35f31430fd05a9baef822caa4a4464f8112 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 22:10:18 +0100 Subject: [PATCH 2/2] docs(docs): document heading deep links --- CHANGELOG.md | 1 + docs/docs-editing.md | 6 ++++-- internal/cmd/docs_enumerators_test.go | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9905290ab..ff2f90546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Contacts: add guarded `contacts dedupe --apply` merging with exact dry-run plans, repeatable `--resource` scoping, confirmation, full updatable-field preservation, etag checks before deletion, and refusal of ambiguous or unmergeable groups. (#815) — thanks @privatenumber. +- Docs: expose heading IDs in `docs headings list --json` and `docs paragraphs list --json` for building in-document deep links. (#819, #820) — thanks @sebsnyk. ### Changed diff --git a/docs/docs-editing.md b/docs/docs-editing.md index 482f262df..4731e65bd 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -129,8 +129,10 @@ gog docs paragraphs list --style NORMAL_TEXT --tab "Notes" All four commands accept `--tab` by title or ID. JSON output includes stable element indexes and Docs API positions; `--plain` emits headerless TSV for -shell pipelines. Paragraph JSON also reports `isEmpty` plus each text run's -UTF-16 range, text style, and link metadata. +shell pipelines. Heading and paragraph JSON includes `headingId` when present, +which can be used in a `#heading=` document URL. Paragraph JSON also reports +`isEmpty` plus each text run's UTF-16 range, text style, and link metadata, +including bookmark and heading link IDs. Command pages: diff --git a/internal/cmd/docs_enumerators_test.go b/internal/cmd/docs_enumerators_test.go index d101164d7..b9003043c 100644 --- a/internal/cmd/docs_enumerators_test.go +++ b/internal/cmd/docs_enumerators_test.go @@ -231,6 +231,15 @@ func TestDocsHeadingsAndParagraphsJSONIncludeHeadingID(t *testing.T) { if len(headings.Headings) != 1 || headings.Headings[0].HeadingID != "h.section" { t.Fatalf("headings = %#v", headings.Headings) } + var rawHeadings struct { + Headings []map[string]json.RawMessage `json:"headings"` + } + if err := json.Unmarshal(output.Bytes(), &rawHeadings); err != nil { + t.Fatalf("unmarshal raw headings: %v\n%s", err, output.String()) + } + if _, ok := rawHeadings.Headings[0]["headingId"]; !ok { + t.Fatalf("heading JSON missing headingId: %s", output.String()) + } output.Reset() if err := runKong(t, &DocsParagraphsListCmd{}, []string{"doc1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { @@ -248,6 +257,18 @@ func TestDocsHeadingsAndParagraphsJSONIncludeHeadingID(t *testing.T) { if paragraphs.Paragraphs[1].HeadingID != "" { t.Fatalf("normal paragraph headingId = %q, want empty", paragraphs.Paragraphs[1].HeadingID) } + var rawParagraphs struct { + Paragraphs []map[string]json.RawMessage `json:"paragraphs"` + } + if err := json.Unmarshal(output.Bytes(), &rawParagraphs); err != nil { + t.Fatalf("unmarshal raw paragraphs: %v\n%s", err, output.String()) + } + if _, ok := rawParagraphs.Paragraphs[0]["headingId"]; !ok { + t.Fatalf("heading paragraph JSON missing headingId: %s", output.String()) + } + if _, ok := rawParagraphs.Paragraphs[1]["headingId"]; ok { + t.Fatalf("normal paragraph JSON includes empty headingId: %s", output.String()) + } } func TestDocsParagraphsJSONIncludesRunsAndEmptiness(t *testing.T) {