476 lines
14 KiB
Go
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
|
|
}
|