gogcli/internal/cmd/sheets.go
2026-01-16 09:52:44 +00:00

476 lines
14 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"google.golang.org/api/sheets/v4"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
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, `\!`, "!")
}
type SheetsCmd struct {
Get SheetsGetCmd `cmd:"" name:"get" help:"Get values from a range"`
Update SheetsUpdateCmd `cmd:"" name:"update" help:"Update values in a range"`
Append SheetsAppendCmd `cmd:"" name:"append" help:"Append values to a range"`
Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"`
Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"`
Metadata SheetsMetadataCmd `cmd:"" name:"metadata" help:"Get spreadsheet metadata"`
Create SheetsCreateCmd `cmd:"" name:"create" help:"Create a new spreadsheet"`
Copy SheetsCopyCmd `cmd:"" name:"copy" help:"Copy a Google Sheet"`
Export SheetsExportCmd `cmd:"" name:"export" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"`
}
type SheetsExportCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Output OutputPathFlag `embed:""`
Format string `name:"format" help:"Export format: pdf|xlsx|csv" default:"xlsx"`
}
func (c *SheetsExportCmd) Run(ctx context.Context, flags *RootFlags) error {
return exportViaDrive(ctx, flags, exportViaDriveOptions{
ArgName: "spreadsheetId",
ExpectedMime: "application/vnd.google-apps.spreadsheet",
KindLabel: "Google Sheet",
DefaultFormat: "xlsx",
FormatHelp: "Export format: pdf|xlsx|csv",
}, c.SpreadsheetID, c.Output.Path, c.Format)
}
type SheetsCopyCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Title string `arg:"" name:"title" help:"New spreadsheet title"`
Parent string `name:"parent" help:"Destination folder ID"`
}
func (c *SheetsCopyCmd) Run(ctx context.Context, flags *RootFlags) error {
return copyViaDrive(ctx, flags, copyViaDriveOptions{
ArgName: "spreadsheetId",
ExpectedMime: "application/vnd.google-apps.spreadsheet",
KindLabel: "Google Sheet",
}, c.SpreadsheetID, c.Title, c.Parent)
}
type SheetsGetCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A1:B10)"`
MajorDimension string `name:"dimension" help:"Major dimension: ROWS or COLUMNS"`
ValueRenderOption string `name:"render" help:"Value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA"`
}
func (c *SheetsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := 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
}
call := svc.Spreadsheets.Values.Get(spreadsheetID, rangeSpec)
if strings.TrimSpace(c.MajorDimension) != "" {
call = call.MajorDimension(c.MajorDimension)
}
if strings.TrimSpace(c.ValueRenderOption) != "" {
call = call.ValueRenderOption(c.ValueRenderOption)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
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
}
type SheetsUpdateCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A1:B2)"`
Values []string `arg:"" optional:"" name:"values" help:"Values (comma-separated rows, pipe-separated cells)"`
ValueInput string `name:"input" help:"Value input option: RAW or USER_ENTERED" default:"USER_ENTERED"`
ValuesJSON string `name:"values-json" help:"Values as JSON 2D array"`
CopyValidationFrom string `name:"copy-validation-from" help:"Copy data validation from an A1 range (eg. 'Sheet1!A2:D2') to the updated cells"`
}
func (c *SheetsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := strings.TrimSpace(c.SpreadsheetID)
rangeSpec := cleanRange(c.Range)
if spreadsheetID == "" {
return usage("empty spreadsheetId")
}
if strings.TrimSpace(rangeSpec) == "" {
return usage("empty range")
}
var values [][]interface{}
switch {
case strings.TrimSpace(c.ValuesJSON) != "":
if unmarshalErr := json.Unmarshal([]byte(c.ValuesJSON), &values); unmarshalErr != nil {
return fmt.Errorf("invalid JSON values: %w", unmarshalErr)
}
case len(c.Values) > 0:
// Parse comma-separated rows, pipe-separated cells
rawValues := strings.Join(c.Values, " ")
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)
}
default:
return fmt.Errorf("provide values as args or via --values-json")
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Update(spreadsheetID, rangeSpec, vr)
valueInputOption := strings.TrimSpace(c.ValueInput)
if valueInputOption == "" {
valueInputOption = "USER_ENTERED"
}
call = call.ValueInputOption(valueInputOption)
resp, err := call.Do()
if err != nil {
return err
}
if strings.TrimSpace(c.CopyValidationFrom) != "" {
if strings.TrimSpace(resp.UpdatedRange) == "" {
return fmt.Errorf("update response missing updated range for validation copy")
}
if err := copyDataValidation(ctx, svc, spreadsheetID, c.CopyValidationFrom, resp.UpdatedRange); err != nil {
return err
}
}
if outfmt.IsJSON(ctx) {
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
}
type SheetsAppendCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A:C)"`
Values []string `arg:"" optional:"" name:"values" help:"Values (comma-separated rows, pipe-separated cells)"`
ValueInput string `name:"input" help:"Value input option: RAW or USER_ENTERED" default:"USER_ENTERED"`
Insert string `name:"insert" help:"Insert data option: OVERWRITE or INSERT_ROWS"`
ValuesJSON string `name:"values-json" help:"Values as JSON 2D array"`
CopyValidationFrom string `name:"copy-validation-from" help:"Copy data validation from an A1 range (eg. 'Sheet1!A2:D2') to the appended cells"`
}
func (c *SheetsAppendCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := strings.TrimSpace(c.SpreadsheetID)
rangeSpec := cleanRange(c.Range)
if spreadsheetID == "" {
return usage("empty spreadsheetId")
}
if strings.TrimSpace(rangeSpec) == "" {
return usage("empty range")
}
var values [][]interface{}
switch {
case strings.TrimSpace(c.ValuesJSON) != "":
if unmarshalErr := json.Unmarshal([]byte(c.ValuesJSON), &values); unmarshalErr != nil {
return fmt.Errorf("invalid JSON values: %w", unmarshalErr)
}
case len(c.Values) > 0:
rawValues := strings.Join(c.Values, " ")
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)
}
default:
return fmt.Errorf("provide values as args or via --values-json")
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Append(spreadsheetID, rangeSpec, vr)
valueInputOption := strings.TrimSpace(c.ValueInput)
if valueInputOption == "" {
valueInputOption = "USER_ENTERED"
}
call = call.ValueInputOption(valueInputOption)
if strings.TrimSpace(c.Insert) != "" {
call = call.InsertDataOption(c.Insert)
}
resp, err := call.Do()
if err != nil {
return err
}
if strings.TrimSpace(c.CopyValidationFrom) != "" {
if resp.Updates == nil || strings.TrimSpace(resp.Updates.UpdatedRange) == "" {
return fmt.Errorf("append response missing updated range for validation copy")
}
if err := copyDataValidation(ctx, svc, spreadsheetID, c.CopyValidationFrom, resp.Updates.UpdatedRange); err != nil {
return err
}
}
if outfmt.IsJSON(ctx) {
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
}
type SheetsClearCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A1:B2)"`
}
func (c *SheetsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := 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.Values.Clear(spreadsheetID, rangeSpec, &sheets.ClearValuesRequest{}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"clearedRange": resp.ClearedRange,
})
}
u.Out().Printf("Cleared %s", resp.ClearedRange)
return nil
}
type SheetsMetadataCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
}
func (c *SheetsMetadataCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := strings.TrimSpace(c.SpreadsheetID)
if spreadsheetID == "" {
return usage("empty spreadsheetId")
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
resp, err := svc.Spreadsheets.Get(spreadsheetID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
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
}
type SheetsCreateCmd struct {
Title string `arg:"" name:"title" help:"Spreadsheet title"`
Sheets string `name:"sheets" help:"Comma-separated sheet names to create"`
}
func (c *SheetsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
title := strings.TrimSpace(c.Title)
if title == "" {
return usage("empty title")
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
spreadsheet := &sheets.Spreadsheet{
Properties: &sheets.SpreadsheetProperties{
Title: title,
},
}
if strings.TrimSpace(c.Sheets) != "" {
names := strings.Split(c.Sheets, ",")
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(ctx) {
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
}