Skip to content

Commit 0ed8997

Browse files
committed
fix(sheets): cover rich-text hyperlinks in links command (#374) (thanks @omothm)
1 parent ddc1a23 commit 0ed8997

5 files changed

Lines changed: 179 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.
7+
- Sheets: add `sheets links` (alias `hyperlinks`) to list cell links from ranges, including rich-text links. (#374) — thanks @omothm.
78
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
89
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
910
- Drive: add `drive ls --all` (alias `--global`) to list across all accessible files; make `--all` and `--parent` mutually exclusive. (#107) — thanks @struong.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,7 @@ gog sheets export <spreadsheetId> --format pdf --out ./sheet.pdf
906906
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold'
907907
gog sheets insert <spreadsheetId> "Sheet1" rows 2 --count 3
908908
gog sheets notes <spreadsheetId> 'Sheet1!A1:B10'
909+
gog sheets links <spreadsheetId> 'Sheet1!A1:B10'
909910
```
910911

911912
### Contacts
@@ -997,6 +998,7 @@ gog sheets insert <spreadsheetId> "Sheet1" cols 3 --after
997998

998999
# Notes
9991000
gog sheets notes <spreadsheetId> 'Sheet1!A1:B10'
1001+
gog sheets links <spreadsheetId> 'Sheet1!A1:B10' # Includes rich-text links
10001002

10011003
# Create
10021004
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"

internal/cmd/sheets_links.go

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"os"
77
"strings"
88

9+
"google.golang.org/api/sheets/v4"
10+
911
"github.com/steipete/gogcli/internal/outfmt"
1012
"github.com/steipete/gogcli/internal/ui"
1113
)
@@ -39,7 +41,7 @@ func (c *SheetsLinksCmd) Run(ctx context.Context, flags *RootFlags) error {
3941
resp, err := svc.Spreadsheets.Get(spreadsheetID).
4042
Ranges(rangeSpec).
4143
IncludeGridData(true).
42-
Fields("sheets(properties(title),data(startRow,startColumn,rowData(values(hyperlink,formattedValue))))").
44+
Fields("sheets(properties(title),data(startRow,startColumn,rowData(values(hyperlink,formattedValue,userEnteredFormat(textFormat(link(uri))),textFormatRuns(format(link(uri)))))))").
4345
Do()
4446
if err != nil {
4547
return err
@@ -78,19 +80,22 @@ func (c *SheetsLinksCmd) Run(ctx context.Context, flags *RootFlags) error {
7880
if cell == nil {
7981
continue
8082
}
81-
if cell.Hyperlink == "" {
83+
cellLinks := extractCellLinks(cell)
84+
if len(cellLinks) == 0 {
8285
continue
8386
}
8487
absRow := startRow + ri + 1
8588
absCol := startCol + ci + 1
86-
links = append(links, cellLink{
87-
Sheet: sheetTitle,
88-
A1: formatA1Cell(sheetTitle, absRow, absCol),
89-
Row: absRow,
90-
Col: absCol,
91-
Value: cell.FormattedValue,
92-
Link: cell.Hyperlink,
93-
})
89+
for _, link := range cellLinks {
90+
links = append(links, cellLink{
91+
Sheet: sheetTitle,
92+
A1: formatA1Cell(sheetTitle, absRow, absCol),
93+
Row: absRow,
94+
Col: absCol,
95+
Value: cell.FormattedValue,
96+
Link: link,
97+
})
98+
}
9499
}
95100
}
96101
}
@@ -121,3 +126,38 @@ func (c *SheetsLinksCmd) Run(ctx context.Context, flags *RootFlags) error {
121126
}
122127
return nil
123128
}
129+
130+
func extractCellLinks(cell *sheets.CellData) []string {
131+
if cell == nil {
132+
return nil
133+
}
134+
135+
seen := make(map[string]struct{})
136+
links := make([]string, 0, 1)
137+
add := func(link string) {
138+
trimmed := strings.TrimSpace(link)
139+
if trimmed == "" {
140+
return
141+
}
142+
if _, ok := seen[trimmed]; ok {
143+
return
144+
}
145+
seen[trimmed] = struct{}{}
146+
links = append(links, trimmed)
147+
}
148+
149+
add(cell.Hyperlink)
150+
151+
if cell.UserEnteredFormat != nil && cell.UserEnteredFormat.TextFormat != nil && cell.UserEnteredFormat.TextFormat.Link != nil {
152+
add(cell.UserEnteredFormat.TextFormat.Link.Uri)
153+
}
154+
155+
for _, run := range cell.TextFormatRuns {
156+
if run == nil || run.Format == nil || run.Format.Link == nil {
157+
continue
158+
}
159+
add(run.Format.Link.Uri)
160+
}
161+
162+
return links
163+
}

internal/cmd/sheets_links_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"net/http/httptest"
99
"os"
10+
"reflect"
1011
"strings"
1112
"testing"
1213

@@ -285,3 +286,122 @@ func TestSheetsLinksCmd_NoLinks(t *testing.T) {
285286
t.Errorf("expected 'No links found' on stderr: %q", errOut)
286287
}
287288
}
289+
290+
func TestSheetsLinksCmd_RichTextRunsAndCellLevelLinks(t *testing.T) {
291+
origNew := newSheetsService
292+
t.Cleanup(func() { newSheetsService = origNew })
293+
294+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
295+
w.Header().Set("Content-Type", "application/json")
296+
_ = json.NewEncoder(w).Encode(map[string]any{
297+
"sheets": []map[string]any{
298+
{
299+
"properties": map[string]any{
300+
"title": "Sheet1",
301+
},
302+
"data": []map[string]any{
303+
{
304+
"startRow": 2,
305+
"startColumn": 2,
306+
"rowData": []map[string]any{
307+
{
308+
"values": []map[string]any{
309+
{
310+
"formattedValue": "Rich links",
311+
"userEnteredFormat": map[string]any{
312+
"textFormat": map[string]any{
313+
"link": map[string]any{"uri": "https://cell.example"},
314+
},
315+
},
316+
"textFormatRuns": []map[string]any{
317+
{"startIndex": 0, "format": map[string]any{"link": map[string]any{"uri": "https://cell.example"}}},
318+
{"startIndex": 4, "format": map[string]any{"link": map[string]any{"uri": "https://run1.example"}}},
319+
{"startIndex": 8, "format": map[string]any{"link": map[string]any{"uri": " https://run2.example "}}},
320+
},
321+
},
322+
},
323+
},
324+
},
325+
},
326+
},
327+
},
328+
},
329+
})
330+
}))
331+
defer srv.Close()
332+
333+
svc, err := sheets.NewService(context.Background(),
334+
option.WithoutAuthentication(),
335+
option.WithHTTPClient(srv.Client()),
336+
option.WithEndpoint(srv.URL+"/"),
337+
)
338+
if err != nil {
339+
t.Fatalf("NewService: %v", err)
340+
}
341+
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil }
342+
343+
flags := &RootFlags{Account: "a@b.com"}
344+
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
345+
if uiErr != nil {
346+
t.Fatalf("ui.New: %v", uiErr)
347+
}
348+
ctx := ui.WithUI(context.Background(), u)
349+
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
350+
351+
out := captureStdout(t, func() {
352+
if err := runKong(t, &SheetsLinksCmd{}, []string{"s1", "Sheet1!C3"}, ctx, flags); err != nil {
353+
t.Fatalf("links: %v", err)
354+
}
355+
})
356+
357+
var result map[string]any
358+
if err := json.Unmarshal([]byte(out), &result); err != nil {
359+
t.Fatalf("unmarshal: %v (output: %q)", err, out)
360+
}
361+
362+
linksAny, ok := result["links"].([]any)
363+
if !ok {
364+
t.Fatalf("expected links array, got %T", result["links"])
365+
}
366+
if len(linksAny) != 3 {
367+
t.Fatalf("expected 3 deduped links, got %d", len(linksAny))
368+
}
369+
370+
got := make([]string, 0, len(linksAny))
371+
for _, entry := range linksAny {
372+
row := entry.(map[string]any)
373+
if row["a1"] != "Sheet1!C3" {
374+
t.Fatalf("unexpected a1: %v", row["a1"])
375+
}
376+
got = append(got, row["link"].(string))
377+
}
378+
379+
want := []string{"https://cell.example", "https://run1.example", "https://run2.example"}
380+
if !reflect.DeepEqual(got, want) {
381+
t.Fatalf("unexpected links: got %v want %v", got, want)
382+
}
383+
}
384+
385+
func TestExtractCellLinks(t *testing.T) {
386+
cell := &sheets.CellData{
387+
Hyperlink: " https://a.example ",
388+
UserEnteredFormat: &sheets.CellFormat{
389+
TextFormat: &sheets.TextFormat{
390+
Link: &sheets.Link{Uri: "https://a.example"},
391+
},
392+
},
393+
TextFormatRuns: []*sheets.TextFormatRun{
394+
{Format: &sheets.TextFormat{Link: &sheets.Link{Uri: "https://b.example"}}},
395+
{Format: &sheets.TextFormat{Link: &sheets.Link{Uri: "https://a.example"}}},
396+
nil,
397+
{Format: nil},
398+
{Format: &sheets.TextFormat{Link: &sheets.Link{Uri: " "}}},
399+
},
400+
}
401+
402+
got := extractCellLinks(cell)
403+
want := []string{"https://a.example", "https://b.example"}
404+
if !reflect.DeepEqual(got, want) {
405+
t.Fatalf("unexpected links: got %v want %v", got, want)
406+
}
407+
}

internal/cmd/sheets_validation_more_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ func TestSheetsClearMetadataCreate_ValidationErrors(t *testing.T) {
197197
if err := (&SheetsMetadataCmd{}).Run(ctx, flags); err == nil {
198198
t.Fatalf("expected metadata missing spreadsheetId error")
199199
}
200+
if err := (&SheetsLinksCmd{}).Run(ctx, flags); err == nil {
201+
t.Fatalf("expected links missing spreadsheetId error")
202+
}
203+
if err := (&SheetsLinksCmd{SpreadsheetID: "s1"}).Run(ctx, flags); err == nil {
204+
t.Fatalf("expected links missing range error")
205+
}
200206
if err := (&SheetsCreateCmd{}).Run(ctx, flags); err == nil {
201207
t.Fatalf("expected create missing title error")
202208
}

0 commit comments

Comments
 (0)