// Package sheets provides a typed wrapper around the Google Sheets v4 API. package sheets import ( "context" "fmt" "time" "google.golang.org/api/option" sheetsv4 "google.golang.org/api/sheets/v4" ) // ValueRange pairs an R1C1 range with its cell values, used for batchUpdate. type ValueRange struct { Range string // R1C1 notation, e.g. "R2C4:R2C6" Values [][]any // one sub-slice per row } // Client wraps the Sheets v4 API with the operations needed by this project. type Client struct { svc *sheetsv4.Service } // New builds a Client using a service-account credentials file. func New(ctx context.Context, credentialsPath string, _ time.Duration) (*Client, error) { svc, err := sheetsv4.NewService(ctx, option.WithCredentialsFile(credentialsPath), //nolint:staticcheck option.WithScopes(sheetsv4.SpreadsheetsScope), ) if err != nil { return nil, err } return &Client{svc: svc}, nil } // GetValues fetches a range from a spreadsheet with UNFORMATTED_VALUE rendering // (numbers as numbers, dates as serial floats — matching Python's behaviour). func (c *Client) GetValues(ctx context.Context, spreadsheetID, a1Range string) ([][]any, error) { resp, err := c.svc.Spreadsheets.Values. Get(spreadsheetID, a1Range). ValueRenderOption("UNFORMATTED_VALUE"). Context(ctx). Do() if err != nil { return nil, err } rows := make([][]any, len(resp.Values)) copy(rows, resp.Values) return rows, nil } // AppendValues appends rows to the first empty row after a1Range. func (c *Client) AppendValues(ctx context.Context, spreadsheetID, a1Range string, rows [][]any) error { vals := make([][]any, len(rows)) copy(vals, rows) _, err := c.svc.Spreadsheets.Values. Append(spreadsheetID, a1Range, &sheetsv4.ValueRange{Values: vals}). ValueInputOption("USER_ENTERED"). Context(ctx). Do() return err } // BatchUpdateValues writes multiple non-contiguous ranges in one API call. func (c *Client) BatchUpdateValues(ctx context.Context, spreadsheetID string, updates []ValueRange) error { data := make([]*sheetsv4.ValueRange, len(updates)) for i, u := range updates { vals := make([][]any, len(u.Values)) copy(vals, u.Values) data[i] = &sheetsv4.ValueRange{Range: u.Range, Values: vals} } _, err := c.svc.Spreadsheets.Values. BatchUpdate(spreadsheetID, &sheetsv4.BatchUpdateValuesRequest{ ValueInputOption: "USER_ENTERED", Data: data, }). Context(ctx). Do() return err } // WriteHeader overwrites row 1 of the spreadsheet with the given labels. func (c *Client) WriteHeader(ctx context.Context, spreadsheetID string, labels []string) error { row := make([]any, len(labels)) for i, l := range labels { row[i] = l } _, err := c.svc.Spreadsheets.Values. Update(spreadsheetID, "A1", &sheetsv4.ValueRange{Values: [][]any{row}}). ValueInputOption("USER_ENTERED"). Context(ctx). Do() return err } // SortByDateColumn sorts rows 2..10000 of the first sheet ascending by column A (Date). // Looks up the sheetId (gid) from spreadsheet metadata. func (c *Client) SortByDateColumn(ctx context.Context, spreadsheetID string) error { meta, err := c.svc.Spreadsheets.Get(spreadsheetID).Context(ctx).Do() if err != nil { return fmt.Errorf("sheets: get spreadsheet: %w", err) } if len(meta.Sheets) == 0 { return fmt.Errorf("sheets: spreadsheet has no sheets") } sheetID := meta.Sheets[0].Properties.SheetId _, err = c.svc.Spreadsheets.BatchUpdate(spreadsheetID, &sheetsv4.BatchUpdateSpreadsheetRequest{ Requests: []*sheetsv4.Request{{ SortRange: &sheetsv4.SortRangeRequest{ Range: &sheetsv4.GridRange{ SheetId: sheetID, StartRowIndex: 1, EndRowIndex: 10000, }, SortSpecs: []*sheetsv4.SortSpec{{ DimensionIndex: 0, SortOrder: "ASCENDING", }}, }, }}, }).Context(ctx).Do() return err }