diff --git a/CHANGELOG.md b/CHANGELOG.md index 9974ec21..ea0bd035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon. +- Sheets: add `sheets links` (alias `hyperlinks`) to list cell links from ranges, including rich-text links. (#374) — thanks @omothm. - Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella. - Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97. - Drive: add `drive ls --all` (alias `--global`) to list across all accessible files; make `--all` and `--parent` mutually exclusive. (#107) — thanks @struong. diff --git a/README.md b/README.md index 86c4a723..8d071122 100644 --- a/README.md +++ b/README.md @@ -906,6 +906,7 @@ gog sheets export --format pdf --out ./sheet.pdf gog sheets format 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold' gog sheets insert "Sheet1" rows 2 --count 3 gog sheets notes 'Sheet1!A1:B10' +gog sheets links 'Sheet1!A1:B10' ``` ### Contacts @@ -997,6 +998,7 @@ gog sheets insert "Sheet1" cols 3 --after # Notes gog sheets notes 'Sheet1!A1:B10' +gog sheets links 'Sheet1!A1:B10' # Includes rich-text links # Create gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2" diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 49f1cb25..2d54e829 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -31,6 +31,7 @@ type SheetsCmd struct { Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"` Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"` Notes SheetsNotesCmd `cmd:"" name:"notes" help:"Get cell notes from a range"` + Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"` Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"` Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"` Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"` diff --git a/internal/cmd/sheets_links.go b/internal/cmd/sheets_links.go new file mode 100644 index 00000000..507fe25d --- /dev/null +++ b/internal/cmd/sheets_links.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsLinksCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A1:B10)"` +} + +func (c *SheetsLinksCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + rangeSpec := cleanRange(c.Range) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if strings.TrimSpace(rangeSpec) == "" { + return usage("empty range") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Ranges(rangeSpec). + IncludeGridData(true). + Fields("sheets(properties(title),data(startRow,startColumn,rowData(values(hyperlink,formattedValue,userEnteredFormat(textFormat(link(uri))),textFormatRuns(format(link(uri)))))))"). + Do() + if err != nil { + return err + } + + type cellLink struct { + Sheet string `json:"sheet"` + A1 string `json:"a1"` + Row int `json:"row"` + Col int `json:"col"` + Value string `json:"value"` + Link string `json:"link"` + } + + var links []cellLink + + for _, sheet := range resp.Sheets { + if sheet == nil { + continue + } + sheetTitle := "" + if sheet.Properties != nil { + sheetTitle = strings.TrimSpace(sheet.Properties.Title) + } + for _, data := range sheet.Data { + if data == nil { + continue + } + startRow := int(data.StartRow) + startCol := int(data.StartColumn) + for ri, row := range data.RowData { + if row == nil { + continue + } + for ci, cell := range row.Values { + if cell == nil { + continue + } + cellLinks := extractCellLinks(cell) + if len(cellLinks) == 0 { + continue + } + absRow := startRow + ri + 1 + absCol := startCol + ci + 1 + for _, link := range cellLinks { + links = append(links, cellLink{ + Sheet: sheetTitle, + A1: formatA1Cell(sheetTitle, absRow, absCol), + Row: absRow, + Col: absCol, + Value: cell.FormattedValue, + Link: link, + }) + } + } + } + } + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "range": rangeSpec, + "links": links, + }) + } + + if len(links) == 0 { + u.Err().Println("No links found") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "A1\tVALUE\tLINK") + for _, l := range links { + fmt.Fprintf(w, "%s\t%s\t%s\n", + oneLine(l.A1), + oneLine(l.Value), + oneLine(l.Link), + ) + } + return nil +} + +func extractCellLinks(cell *sheets.CellData) []string { + if cell == nil { + return nil + } + + seen := make(map[string]struct{}) + links := make([]string, 0, 1) + add := func(link string) { + trimmed := strings.TrimSpace(link) + if trimmed == "" { + return + } + if _, ok := seen[trimmed]; ok { + return + } + seen[trimmed] = struct{}{} + links = append(links, trimmed) + } + + add(cell.Hyperlink) + + if cell.UserEnteredFormat != nil && cell.UserEnteredFormat.TextFormat != nil && cell.UserEnteredFormat.TextFormat.Link != nil { + add(cell.UserEnteredFormat.TextFormat.Link.Uri) + } + + for _, run := range cell.TextFormatRuns { + if run == nil || run.Format == nil || run.Format.Link == nil { + continue + } + add(run.Format.Link.Uri) + } + + return links +} diff --git a/internal/cmd/sheets_links_test.go b/internal/cmd/sheets_links_test.go new file mode 100644 index 00000000..7e8c9cd7 --- /dev/null +++ b/internal/cmd/sheets_links_test.go @@ -0,0 +1,407 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func linksHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + if strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet { + if r.URL.Query().Get("includeGridData") != "true" { + http.Error(w, "expected includeGridData=true", http.StatusBadRequest) + return + } + + rangeParam := r.URL.Query().Get("ranges") + startRow, startCol := 0, 0 + if strings.Contains(rangeParam, "B2") { + startRow, startCol = 1, 1 + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sheets": []map[string]any{ + { + "properties": map[string]any{ + "title": "Sheet1", + }, + "data": []map[string]any{ + { + "startRow": startRow, + "startColumn": startCol, + "rowData": []map[string]any{ + { + "values": []map[string]any{ + {"formattedValue": "Google", "hyperlink": "https://google.com"}, + {"formattedValue": "Age"}, + }, + }, + { + "values": []map[string]any{ + {"formattedValue": "GitHub", "hyperlink": "https://github.com"}, + {"formattedValue": "30"}, + }, + }, + { + "values": []map[string]any{ + {"formattedValue": "Bob"}, + {"formattedValue": "Docs", "hyperlink": "https://docs.google.com"}, + }, + }, + }, + }, + }, + }, + }, + }) + return + } + http.NotFound(w, r) + }) +} + +func TestSheetsLinksCmd_JSON(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + srv := httptest.NewServer(linksHandler()) + defer srv.Close() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + cmd := &SheetsLinksCmd{} + if err := runKong(t, cmd, []string{"s1", "Sheet1!A1:B3"}, ctx, flags); err != nil { + t.Fatalf("links: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + links, ok := result["links"].([]any) + if !ok { + t.Fatalf("expected links array, got %T", result["links"]) + } + if len(links) != 3 { + t.Fatalf("expected 3 links, got %d", len(links)) + } + + first := links[0].(map[string]any) + if first["sheet"] != "Sheet1" { + t.Errorf("expected sheet 'Sheet1', got %q", first["sheet"]) + } + if first["a1"] != "Sheet1!A1" { + t.Errorf("expected a1 'Sheet1!A1', got %q", first["a1"]) + } + if first["row"] != float64(1) { + t.Errorf("expected row 1, got %v", first["row"]) + } + if first["col"] != float64(1) { + t.Errorf("expected col 1, got %v", first["col"]) + } + if first["link"] != "https://google.com" { + t.Errorf("expected 'https://google.com', got %q", first["link"]) + } + if first["value"] != "Google" { + t.Errorf("expected 'Google', got %q", first["value"]) + } +} + +func TestSheetsLinksCmd_Text(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + srv := httptest.NewServer(linksHandler()) + defer srv.Close() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + + out := captureStdout(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &SheetsLinksCmd{}, []string{"s1", "Sheet1!A1:B3"}, ctx, flags); err != nil { + t.Fatalf("links: %v", err) + } + }) + + if !strings.Contains(out, "https://google.com") { + t.Errorf("expected 'https://google.com' in output: %q", out) + } + if !strings.Contains(out, "https://docs.google.com") { + t.Errorf("expected 'https://docs.google.com' in output: %q", out) + } + if !strings.Contains(out, "A1") { + t.Errorf("expected table header in output: %q", out) + } +} + +func TestSheetsLinksCmd_OffsetRange_JSON(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + srv := httptest.NewServer(linksHandler()) + defer srv.Close() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsLinksCmd{}, []string{"s1", "Sheet1!B2:C3"}, ctx, flags); err != nil { + t.Fatalf("links: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + links := result["links"].([]any) + first := links[0].(map[string]any) + if first["a1"] != "Sheet1!B2" { + t.Errorf("expected a1 'Sheet1!B2', got %q", first["a1"]) + } + if first["row"] != float64(2) { + t.Errorf("expected row 2, got %v", first["row"]) + } + if first["col"] != float64(2) { + t.Errorf("expected col 2, got %v", first["col"]) + } +} + +func TestSheetsLinksCmd_NoLinks(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sheets": []map[string]any{ + { + "data": []map[string]any{ + { + "rowData": []map[string]any{ + { + "values": []map[string]any{ + {"formattedValue": "Name"}, + }, + }, + }, + }, + }, + }, + }, + }) + })) + defer srv.Close() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + errOut := captureStderr(t, func() { + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: os.Stderr, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &SheetsLinksCmd{}, []string{"s1", "Sheet1!A1"}, ctx, flags); err != nil { + t.Fatalf("links: %v", err) + } + }) + + if !strings.Contains(errOut, "No links found") { + t.Errorf("expected 'No links found' on stderr: %q", errOut) + } +} + +func TestSheetsLinksCmd_RichTextRunsAndCellLevelLinks(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sheets": []map[string]any{ + { + "properties": map[string]any{ + "title": "Sheet1", + }, + "data": []map[string]any{ + { + "startRow": 2, + "startColumn": 2, + "rowData": []map[string]any{ + { + "values": []map[string]any{ + { + "formattedValue": "Rich links", + "userEnteredFormat": map[string]any{ + "textFormat": map[string]any{ + "link": map[string]any{"uri": "https://cell.example"}, + }, + }, + "textFormatRuns": []map[string]any{ + {"startIndex": 0, "format": map[string]any{"link": map[string]any{"uri": "https://cell.example"}}}, + {"startIndex": 4, "format": map[string]any{"link": map[string]any{"uri": "https://run1.example"}}}, + {"startIndex": 8, "format": map[string]any{"link": map[string]any{"uri": " https://run2.example "}}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + })) + defer srv.Close() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsLinksCmd{}, []string{"s1", "Sheet1!C3"}, ctx, flags); err != nil { + t.Fatalf("links: %v", err) + } + }) + + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("unmarshal: %v (output: %q)", err, out) + } + + linksAny, ok := result["links"].([]any) + if !ok { + t.Fatalf("expected links array, got %T", result["links"]) + } + if len(linksAny) != 3 { + t.Fatalf("expected 3 deduped links, got %d", len(linksAny)) + } + + got := make([]string, 0, len(linksAny)) + for _, entry := range linksAny { + row := entry.(map[string]any) + if row["a1"] != "Sheet1!C3" { + t.Fatalf("unexpected a1: %v", row["a1"]) + } + got = append(got, row["link"].(string)) + } + + want := []string{"https://cell.example", "https://run1.example", "https://run2.example"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected links: got %v want %v", got, want) + } +} + +func TestExtractCellLinks(t *testing.T) { + cell := &sheets.CellData{ + Hyperlink: " https://a.example ", + UserEnteredFormat: &sheets.CellFormat{ + TextFormat: &sheets.TextFormat{ + Link: &sheets.Link{Uri: "https://a.example"}, + }, + }, + TextFormatRuns: []*sheets.TextFormatRun{ + {Format: &sheets.TextFormat{Link: &sheets.Link{Uri: "https://b.example"}}}, + {Format: &sheets.TextFormat{Link: &sheets.Link{Uri: "https://a.example"}}}, + nil, + {Format: nil}, + {Format: &sheets.TextFormat{Link: &sheets.Link{Uri: " "}}}, + }, + } + + got := extractCellLinks(cell) + want := []string{"https://a.example", "https://b.example"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected links: got %v want %v", got, want) + } +} diff --git a/internal/cmd/sheets_validation_more_test.go b/internal/cmd/sheets_validation_more_test.go index 9b042d69..0fec28e1 100644 --- a/internal/cmd/sheets_validation_more_test.go +++ b/internal/cmd/sheets_validation_more_test.go @@ -197,6 +197,12 @@ func TestSheetsClearMetadataCreate_ValidationErrors(t *testing.T) { if err := (&SheetsMetadataCmd{}).Run(ctx, flags); err == nil { t.Fatalf("expected metadata missing spreadsheetId error") } + if err := (&SheetsLinksCmd{}).Run(ctx, flags); err == nil { + t.Fatalf("expected links missing spreadsheetId error") + } + if err := (&SheetsLinksCmd{SpreadsheetID: "s1"}).Run(ctx, flags); err == nil { + t.Fatalf("expected links missing range error") + } if err := (&SheetsCreateCmd{}).Run(ctx, flags); err == nil { t.Fatalf("expected create missing title error") }