gogcli/internal/cmd/sheets.go
2025-12-26 22:24:22 +01:00

476 lines
13 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/sheets/v4"
)
var newSheetsService = googleapi.NewSheets
// cleanRange removes shell escape sequences from range arguments.
// Some shells escape ! to \! (bash history expansion), which breaks Google Sheets API calls.
func cleanRange(r string) string {
return strings.ReplaceAll(r, `\!`, "!")
}
func newSheetsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "sheets",
Short: "Google Sheets",
}
cmd.AddCommand(newSheetsGetCmd(flags))
cmd.AddCommand(newSheetsUpdateCmd(flags))
cmd.AddCommand(newSheetsAppendCmd(flags))
cmd.AddCommand(newSheetsClearCmd(flags))
cmd.AddCommand(newSheetsMetadataCmd(flags))
cmd.AddCommand(newSheetsCreateCmd(flags))
cmd.AddCommand(newSheetsCopyCmd(flags))
cmd.AddCommand(newSheetsExportCmd(flags))
return cmd
}
func newSheetsExportCmd(flags *rootFlags) *cobra.Command {
return newExportViaDriveCmd(flags, exportViaDriveOptions{
Use: "export <spreadsheetId>",
Short: "Export a Google Sheet (pdf|xlsx|csv) via Drive",
ArgName: "spreadsheetId",
ExpectedMime: "application/vnd.google-apps.spreadsheet",
KindLabel: "Google Sheet",
DefaultFormat: "xlsx",
FormatHelp: "Export format: pdf|xlsx|csv",
})
}
func newSheetsCopyCmd(flags *rootFlags) *cobra.Command {
return newCopyViaDriveCmd(flags, copyViaDriveOptions{
Use: "copy <spreadsheetId> <title>",
Short: "Copy a Google Sheet",
ArgName: "spreadsheetId",
ExpectedMime: "application/vnd.google-apps.spreadsheet",
KindLabel: "Google Sheet",
})
}
func newSheetsGetCmd(flags *rootFlags) *cobra.Command {
var majorDimension string
var valueRenderOption string
cmd := &cobra.Command{
Use: "get <spreadsheetId> <range>",
Short: "Get values from a range",
Long: "Get values from a specified range in a Google Sheets spreadsheet.\nExample: gog sheets get 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms 'Sheet1!A1:B10'",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
call := svc.Spreadsheets.Values.Get(spreadsheetID, rangeSpec)
if majorDimension != "" {
call = call.MajorDimension(majorDimension)
}
if valueRenderOption != "" {
call = call.ValueRenderOption(valueRenderOption)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"range": resp.Range,
"values": resp.Values,
})
}
if len(resp.Values) == 0 {
u.Err().Println("No data found")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, row := range resp.Values {
cells := make([]string, len(row))
for i, cell := range row {
cells[i] = fmt.Sprintf("%v", cell)
}
fmt.Fprintln(tw, strings.Join(cells, "\t"))
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().StringVar(&majorDimension, "dimension", "", "Major dimension: ROWS or COLUMNS")
cmd.Flags().StringVar(&valueRenderOption, "render", "", "Value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA")
return cmd
}
func newSheetsUpdateCmd(flags *rootFlags) *cobra.Command {
var valueInputOption string
var jsonValues string
cmd := &cobra.Command{
Use: "update <spreadsheetId> <range> [values...]",
Short: "Update values in a range",
Long: `Update values in a specified range.
Values can be provided as:
1. Command line args (comma-separated rows, pipe-separated cells):
gog sheets update ID 'A1' 'a|b|c,d|e|f' (2 rows, 3 cols each)
2. JSON via --values-json flag:
gog sheets update ID 'A1' --values-json '[["a","b"],["c","d"]]'
Examples:
gog sheets update 1BxiMVs... 'Sheet1!A1' 'Hello|World'
gog sheets update 1BxiMVs... 'Sheet1!A1:B2' --values-json '[["a","b"],["c","d"]]'`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
var values [][]interface{}
if jsonValues != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonValues), &values); unmarshalErr != nil {
return fmt.Errorf("invalid JSON values: %w", unmarshalErr)
}
} else if len(args) > 2 {
// Parse comma-separated rows, pipe-separated cells
rawValues := strings.Join(args[2:], " ")
rows := strings.Split(rawValues, ",")
for _, row := range rows {
cells := strings.Split(strings.TrimSpace(row), "|")
rowData := make([]interface{}, len(cells))
for i, cell := range cells {
rowData[i] = strings.TrimSpace(cell)
}
values = append(values, rowData)
}
} else {
return fmt.Errorf("provide values as args or via --values-json")
}
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Update(spreadsheetID, rangeSpec, vr)
if valueInputOption == "" {
valueInputOption = "USER_ENTERED"
}
call = call.ValueInputOption(valueInputOption)
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"updatedRange": resp.UpdatedRange,
"updatedRows": resp.UpdatedRows,
"updatedColumns": resp.UpdatedColumns,
"updatedCells": resp.UpdatedCells,
})
}
u.Out().Printf("Updated %d cells in %s", resp.UpdatedCells, resp.UpdatedRange)
return nil
},
}
cmd.Flags().StringVar(&valueInputOption, "input", "USER_ENTERED", "Value input option: RAW or USER_ENTERED")
cmd.Flags().StringVar(&jsonValues, "values-json", "", "Values as JSON 2D array")
return cmd
}
func newSheetsAppendCmd(flags *rootFlags) *cobra.Command {
var valueInputOption string
var insertDataOption string
var jsonValues string
cmd := &cobra.Command{
Use: "append <spreadsheetId> <range> [values...]",
Short: "Append values to a range",
Long: `Append values after the last row with data in a range.
Values format same as 'update' command.
Examples:
gog sheets append 1BxiMVs... 'Sheet1!A:C' 'val1|val2|val3'
gog sheets append 1BxiMVs... 'Sheet1!A:C' --values-json '[["a","b","c"]]'`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
var values [][]interface{}
if jsonValues != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonValues), &values); unmarshalErr != nil {
return fmt.Errorf("invalid JSON values: %w", unmarshalErr)
}
} else if len(args) > 2 {
rawValues := strings.Join(args[2:], " ")
rows := strings.Split(rawValues, ",")
for _, row := range rows {
cells := strings.Split(strings.TrimSpace(row), "|")
rowData := make([]interface{}, len(cells))
for i, cell := range cells {
rowData[i] = strings.TrimSpace(cell)
}
values = append(values, rowData)
}
} else {
return fmt.Errorf("provide values as args or via --values-json")
}
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Append(spreadsheetID, rangeSpec, vr)
if valueInputOption == "" {
valueInputOption = "USER_ENTERED"
}
call = call.ValueInputOption(valueInputOption)
if insertDataOption != "" {
call = call.InsertDataOption(insertDataOption)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"updatedRange": resp.Updates.UpdatedRange,
"updatedRows": resp.Updates.UpdatedRows,
"updatedColumns": resp.Updates.UpdatedColumns,
"updatedCells": resp.Updates.UpdatedCells,
})
}
u.Out().Printf("Appended %d cells to %s", resp.Updates.UpdatedCells, resp.Updates.UpdatedRange)
return nil
},
}
cmd.Flags().StringVar(&valueInputOption, "input", "USER_ENTERED", "Value input option: RAW or USER_ENTERED")
cmd.Flags().StringVar(&insertDataOption, "insert", "", "Insert data option: OVERWRITE or INSERT_ROWS")
cmd.Flags().StringVar(&jsonValues, "values-json", "", "Values as JSON 2D array")
return cmd
}
func newSheetsClearCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "clear <spreadsheetId> <range>",
Short: "Clear values in a range",
Long: "Clear all values in a specified range (keeps formatting).",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Spreadsheets.Values.Clear(spreadsheetID, rangeSpec, &sheets.ClearValuesRequest{}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"clearedRange": resp.ClearedRange,
})
}
u.Out().Printf("Cleared %s", resp.ClearedRange)
return nil
},
}
}
func newSheetsMetadataCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "metadata <spreadsheetId>",
Short: "Get spreadsheet metadata",
Long: "Get metadata about a spreadsheet including title, sheets, and properties.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Spreadsheets.Get(spreadsheetID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"spreadsheetId": resp.SpreadsheetId,
"title": resp.Properties.Title,
"locale": resp.Properties.Locale,
"timeZone": resp.Properties.TimeZone,
"sheets": resp.Sheets,
})
}
u.Out().Printf("ID\t%s", resp.SpreadsheetId)
u.Out().Printf("Title\t%s", resp.Properties.Title)
u.Out().Printf("Locale\t%s", resp.Properties.Locale)
u.Out().Printf("TimeZone\t%s", resp.Properties.TimeZone)
u.Out().Printf("URL\t%s", resp.SpreadsheetUrl)
u.Out().Println("")
u.Out().Println("Sheets:")
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE\tROWS\tCOLS")
for _, sheet := range resp.Sheets {
props := sheet.Properties
fmt.Fprintf(tw, "%d\t%s\t%d\t%d\n",
props.SheetId,
props.Title,
props.GridProperties.RowCount,
props.GridProperties.ColumnCount,
)
}
_ = tw.Flush()
return nil
},
}
}
func newSheetsCreateCmd(flags *rootFlags) *cobra.Command {
var sheetNames string
cmd := &cobra.Command{
Use: "create <title>",
Short: "Create a new spreadsheet",
Long: `Create a new Google Sheets spreadsheet.
Examples:
gog sheets create "My Spreadsheet"
gog sheets create "Budget" --sheets "Income,Expenses,Summary"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
title := args[0]
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
spreadsheet := &sheets.Spreadsheet{
Properties: &sheets.SpreadsheetProperties{
Title: title,
},
}
if sheetNames != "" {
names := strings.Split(sheetNames, ",")
spreadsheet.Sheets = make([]*sheets.Sheet, len(names))
for i, name := range names {
spreadsheet.Sheets[i] = &sheets.Sheet{
Properties: &sheets.SheetProperties{
Title: strings.TrimSpace(name),
},
}
}
}
resp, err := svc.Spreadsheets.Create(spreadsheet).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"spreadsheetId": resp.SpreadsheetId,
"title": resp.Properties.Title,
"spreadsheetUrl": resp.SpreadsheetUrl,
})
}
u.Out().Printf("Created spreadsheet: %s", resp.Properties.Title)
u.Out().Printf("ID: %s", resp.SpreadsheetId)
u.Out().Printf("URL: %s", resp.SpreadsheetUrl)
return nil
},
}
cmd.Flags().StringVar(&sheetNames, "sheets", "", "Comma-separated sheet names to create")
return cmd
}