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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/docs-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ gog docs paragraphs list <docId> --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=<id>` 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:

Expand Down
16 changes: 13 additions & 3 deletions internal/cmd/docs_enumerators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -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"`
Expand Down Expand Up @@ -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,
})
}
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -488,6 +493,7 @@ type docsEnumeratedParagraph struct {
StartIndex int64
EndIndex int64
Style string
HeadingID string
Text string
IsEmpty bool
Runs []docsParagraphRun
Expand All @@ -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,
Expand Down
78 changes: 77 additions & 1 deletion internal/cmd/docs_enumerators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,74 @@ 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)
}
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 {
t.Fatalf("paragraphs run: %v", err)
}
var paragraphs struct {
Paragraphs []docsParagraphInspectItem `json:"paragraphs"`
}
if err := json.Unmarshal(output.Bytes(), &paragraphs); 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)
}
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) {
t.Parallel()

Expand Down Expand Up @@ -436,11 +504,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,
Expand Down
Loading