blob: fdc67a181b7e9c2e906579bbb33929b073e62337 [file] [edit]
// Copyright 2026 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package sheets
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"firmweaver/shared/auth"
"golang.org/x/oauth2"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
sheetsapi "google.golang.org/api/sheets/v4"
)
const (
DefaultSheetsAPIBase = "https://sheets.googleapis.com"
APIBaseEnv = "FIRMWEAVER_SHEETS_API_BASE"
// fetchTimeout bounds the total time spent talking to the Sheets API.
// The API client has no timeout of its own, so without this a stalled
// connection would hang the tool indefinitely.
fetchTimeout = 30 * time.Second
)
func getAPIBase() string {
if env := os.Getenv(APIBaseEnv); env != "" {
return env
}
return DefaultSheetsAPIBase
}
func newSheetsService(ctx context.Context, token string) (*sheetsapi.Service, error) {
opts := []option.ClientOption{
option.WithTokenSource(oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: token,
TokenType: "Bearer",
})),
}
if base := getAPIBase(); base != DefaultSheetsAPIBase {
opts = append(opts, option.WithEndpoint(base))
}
return sheetsapi.NewService(ctx, opts...)
}
func formatSheetsAPIError(err error, contextMsg string) error {
var gErr *googleapi.Error
if errors.As(err, &gErr) {
switch gErr.Code {
case http.StatusUnauthorized:
return fmt.Errorf("%s: OAuth token expired or invalid (HTTP 401). Run 'firmweaver auth login' to re-authenticate", contextMsg)
case http.StatusForbidden:
return fmt.Errorf("%s: permission denied (HTTP 403). Ensure your Google account has read access to this sheet", contextMsg)
case http.StatusNotFound:
return fmt.Errorf("%s: spreadsheet or tab not found (HTTP 404). Verify sheet URL or tab name", contextMsg)
case http.StatusTooManyRequests:
return fmt.Errorf("%s: Google Sheets API rate limit / quota exceeded (HTTP 429). Please retry later", contextMsg)
default:
return fmt.Errorf("%s: Google Sheets API returned HTTP %d: %s", contextMsg, gErr.Code, gErr.Message)
}
}
return fmt.Errorf("%s: %w", contextMsg, err)
}
// Fetch resolves the sheet reference and returns the tab's cell matrix.
func Fetch(ctx context.Context, sheetRef, tabOverride string) ([][]string, error) {
spreadsheetID, gid, err := ResolveSheetURL(ctx, sheetRef)
if err != nil {
return nil, err
}
return FetchSheetData(ctx, spreadsheetID, gid, tabOverride)
}
// FetchSheetData retrieves a Google Sheet tab and returns it as a 2D string matrix.
func FetchSheetData(ctx context.Context, spreadsheetID, gid, tabOverride string) ([][]string, error) {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
token, _, err := auth.GetToken(ctx)
if err != nil {
return nil, fmt.Errorf("authentication error: %w", err)
}
srv, err := newSheetsService(ctx, token)
if err != nil {
return nil, fmt.Errorf("failed to create sheets service: %w", err)
}
tabTitle := tabOverride
if tabTitle == "" {
tabTitle, err = resolveTabTitle(ctx, srv, spreadsheetID, gid)
if err != nil {
return nil, err
}
}
fmt.Fprintf(os.Stderr, "Reading Google Sheet tab %q...\n", tabTitle)
escapedTab := "'" + strings.ReplaceAll(tabTitle, "'", "''") + "'"
vr, err := srv.Spreadsheets.Values.Get(spreadsheetID, escapedTab).
ValueRenderOption("FORMATTED_VALUE").
Context(ctx).
Do()
if err != nil {
return nil, formatSheetsAPIError(err, "fetching values")
}
if len(vr.Values) == 0 {
return nil, fmt.Errorf("sheet contains no data rows")
}
maxCols := 0
for _, row := range vr.Values {
if len(row) > maxCols {
maxCols = len(row)
}
}
grid := make([][]string, len(vr.Values))
for r, row := range vr.Values {
grid[r] = make([]string, maxCols)
for c := 0; c < maxCols; c++ {
if c < len(row) && row[c] != nil {
grid[r][c] = strings.TrimSpace(fmt.Sprint(row[c]))
}
}
}
return grid, nil
}
func resolveTabTitle(ctx context.Context, srv *sheetsapi.Service, spreadsheetID, gid string) (string, error) {
resp, err := srv.Spreadsheets.Get(spreadsheetID).
Fields("sheets(properties(sheetId,title))").
Context(ctx).
Do()
if err != nil {
return "", formatSheetsAPIError(err, "querying metadata")
}
if len(resp.Sheets) == 0 {
return "", fmt.Errorf("spreadsheet contains no sheets/tabs")
}
if gid != "" {
targetGID, err := strconv.ParseInt(gid, 10, 64)
if err != nil {
return "", fmt.Errorf("invalid tab gid %q: must be integer", gid)
}
for _, s := range resp.Sheets {
if s.Properties != nil && s.Properties.SheetId == targetGID {
return s.Properties.Title, nil
}
}
return "", fmt.Errorf("sheet tab with gid=%s not found in spreadsheet", gid)
}
if resp.Sheets[0].Properties == nil {
return "", fmt.Errorf("spreadsheet metadata is missing tab properties")
}
return resp.Sheets[0].Properties.Title, nil
}