gogcli/internal/cmd/sheets.go
2026-05-05 08:30:06 +01:00

638 lines
21 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"google.golang.org/api/drive/v3"
"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
const sheetsDefaultValueInputOption = "USER_ENTERED"
// 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" aliases:"read,show" help:"Get values from a range"`
Update SheetsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update values in a range"`
Append SheetsAppendCmd `cmd:"" name:"append" aliases:"add" help:"Append values to a range"`
Insert SheetsInsertCmd `cmd:"" name:"insert" help:"Insert empty rows or columns into a sheet"`
Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"`
Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"`
Conditional SheetsConditionalCmd `cmd:"" name:"conditional-format" aliases:"cf,conditional-formats" help:"Manage conditional formatting rules"`
Banding SheetsBandingCmd `cmd:"" name:"banding" aliases:"banded-ranges" help:"Manage alternating color banding"`
Merge SheetsMergeCmd `cmd:"" name:"merge" help:"Merge cells in a range"`
Unmerge SheetsUnmergeCmd `cmd:"" name:"unmerge" help:"Unmerge cells in a range"`
NumberFormat SheetsNumberFormatCmd `cmd:"" name:"number-format" help:"Apply number format to a range"`
Freeze SheetsFreezeCmd `cmd:"" name:"freeze" help:"Freeze rows and columns on a sheet"`
ResizeColumns SheetsResizeColumnsCmd `cmd:"" name:"resize-columns" help:"Resize sheet columns"`
ResizeRows SheetsResizeRowsCmd `cmd:"" name:"resize-rows" help:"Resize sheet rows"`
ReadFormat SheetsReadFormatCmd `cmd:"" name:"read-format" aliases:"get-format,format-read" help:"Read cell formatting from a range"`
Notes SheetsNotesCmd `cmd:"" name:"notes" help:"Get cell notes from a range"`
UpdateNote SheetsUpdateNoteCmd `cmd:"" name:"update-note" aliases:"set-note" help:"Set or clear a cell note"`
FindReplace SheetsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text across a spreadsheet"`
Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"`
Named SheetsNamedRangesCmd `cmd:"" name:"named-ranges" aliases:"namedranges,nr" help:"Manage named ranges"`
Table SheetsTableCmd `cmd:"" name:"table" aliases:"tables" help:"Manage Google Sheets tables"`
Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"`
Raw SheetsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption)"`
Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"`
Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"`
Export SheetsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"`
Chart SheetsChartCmd `cmd:"" name:"chart" aliases:"charts" help:"Manage spreadsheet charts"`
AddTab SheetsAddTabCmd `cmd:"" name:"add-tab" aliases:"add-sheet" help:"Add a new tab/sheet to a spreadsheet"`
RenameTab SheetsRenameTabCmd `cmd:"" name:"rename-tab" aliases:"rename-sheet" help:"Rename a tab/sheet in a spreadsheet"`
DeleteTab SheetsDeleteTabCmd `cmd:"" name:"delete-tab" aliases:"delete-sheet" help:"Delete a tab/sheet from a spreadsheet (use --force to skip confirmation)"`
}
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{
Op: "sheets.export",
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 (A1 notation or named range name; e.g. Sheet1!A1:B10 or MyNamedRange)"`
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 := normalizeGoogleID(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(ctx, os.Stdout, map[string]any{
"range": resp.Range,
"values": resp.Values,
})
}
if len(resp.Values) == 0 {
u.Err().Println("No data found")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
for _, row := range resp.Values {
cells := make([]string, len(row))
for i, cell := range row {
cells[i] = fmt.Sprintf("%v", cell)
}
fmt.Fprintln(w, strings.Join(cells, "\t"))
}
return nil
}
type SheetsUpdateCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Range string `arg:"" name:"range" help:"Range (A1 notation or named range name; e.g. Sheet1!A1:B2 or MyNamedRange)"`
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 or named range (e.g. 'Sheet1!A2:D2' or MyNamedRange) to the updated cells"`
}
func (c *SheetsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
spreadsheetID := normalizeGoogleID(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) != "":
b, err := resolveInlineOrFileBytes(c.ValuesJSON)
if err != nil {
return fmt.Errorf("read --values-json: %w", err)
}
if unmarshalErr := json.Unmarshal(b, &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")
}
valueInputOption := strings.TrimSpace(c.ValueInput)
if valueInputOption == "" {
valueInputOption = sheetsDefaultValueInputOption
}
if err := dryRunExit(ctx, flags, "sheets.update", map[string]any{
"spreadsheet_id": spreadsheetID,
"range": rangeSpec,
"values": values,
"value_input_option": valueInputOption,
"copy_validation_from": strings.TrimSpace(c.CopyValidationFrom),
"copy_validation_to_hint": "updatedRange",
}); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Update(spreadsheetID, rangeSpec, vr)
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(ctx, 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 (A1 notation or named range name; e.g. Sheet1!A:C or MyNamedRange)"`
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 or named range (e.g. 'Sheet1!A2:D2' or MyNamedRange) to the appended cells"`
}
func (c *SheetsAppendCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
spreadsheetID := normalizeGoogleID(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) != "":
b, err := resolveInlineOrFileBytes(c.ValuesJSON)
if err != nil {
return fmt.Errorf("read --values-json: %w", err)
}
if unmarshalErr := json.Unmarshal(b, &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")
}
valueInputOption := strings.TrimSpace(c.ValueInput)
if valueInputOption == "" {
valueInputOption = sheetsDefaultValueInputOption
}
insertDataOption := strings.TrimSpace(c.Insert)
if err := dryRunExit(ctx, flags, "sheets.append", map[string]any{
"spreadsheet_id": spreadsheetID,
"range": rangeSpec,
"values": values,
"value_input_option": valueInputOption,
"insert_data_option": insertDataOption,
"copy_validation_from": strings.TrimSpace(c.CopyValidationFrom),
}); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Append(spreadsheetID, rangeSpec, vr)
call = call.ValueInputOption(valueInputOption)
if insertDataOption != "" {
call = call.InsertDataOption(insertDataOption)
}
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(ctx, 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 (A1 notation or named range name; e.g. Sheet1!A1:B2 or MyNamedRange)"`
}
func (c *SheetsClearCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID))
rangeSpec := cleanRange(c.Range)
if spreadsheetID == "" {
return usage("empty spreadsheetId")
}
if strings.TrimSpace(rangeSpec) == "" {
return usage("empty range")
}
if err := dryRunExit(ctx, flags, "sheets.clear", map[string]any{
"spreadsheet_id": spreadsheetID,
"range": rangeSpec,
}); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
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(ctx, os.Stdout, map[string]any{
"clearedRange": resp.ClearedRange,
})
}
u.Out().Printf("Cleared %s", resp.ClearedRange)
return nil
}
// SheetsRawCmd dumps the full Spreadsheets.Get response as JSON, with no
// Fields restriction. `--include-grid-data` opts into returning cell-level
// data; it is off by default because grid payloads can be multi-MB and are
// the primary leakage vector (formulas may embed API keys or tokens).
//
// REST reference: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/get
// Go type: https://pkg.go.dev/google.golang.org/api/sheets/v4#Spreadsheet
type SheetsRawCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
IncludeGridData bool `name:"include-grid-data" help:"Include cell-level grid data in the response (off by default; payloads can be large and may contain secrets in formulas)"`
Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"`
}
func (c *SheetsRawCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID))
if spreadsheetID == "" {
return usage("empty spreadsheetId")
}
_, svc, err := requireSheetsService(ctx, flags)
if err != nil {
return err
}
call := svc.Spreadsheets.Get(spreadsheetID).Context(ctx)
if c.IncludeGridData {
call = call.IncludeGridData(true)
u.Err().Println("warning: --include-grid-data may expose cell-level formulas that contain API keys or hardcoded secrets")
}
resp, err := call.Do()
if err != nil {
return err
}
resp, err = requireRawResponse(resp, "spreadsheet not found")
if err != nil {
return err
}
if len(resp.DeveloperMetadata) > 0 {
u.Err().Println("warning: response contains developerMetadata which may hold third-party app secrets")
}
return writeRawJSON(ctx, resp, c.Pretty)
}
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 := normalizeGoogleID(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(ctx, 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:")
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tTITLE\tROWS\tCOLS")
for _, sheet := range resp.Sheets {
props := sheet.Properties
fmt.Fprintf(w, "%d\t%s\t%d\t%d\n",
props.SheetId,
props.Title,
props.GridProperties.RowCount,
props.GridProperties.ColumnCount,
)
}
return nil
}
type SheetsCreateCmd struct {
Title string `arg:"" name:"title" help:"Spreadsheet title"`
Sheets string `name:"sheets" help:"Comma-separated sheet names to create"`
Parent string `name:"parent" help:"Destination folder ID"`
}
func (c *SheetsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
title := strings.TrimSpace(c.Title)
if title == "" {
return usage("empty title")
}
names := splitCSV(c.Sheets)
parent := normalizeGoogleID(strings.TrimSpace(c.Parent))
if err := dryRunExit(ctx, flags, "sheets.create", map[string]any{
"title": title,
"sheets": names,
"parent": parent,
}); err != nil {
return err
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
spreadsheet := &sheets.Spreadsheet{
Properties: &sheets.SpreadsheetProperties{
Title: title,
},
}
if len(names) > 0 {
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
}
movedToParent := false
moveError := ""
if parent != "" {
parentDriveSvc, driveErr := newDriveService(ctx, account)
if driveErr == nil {
var meta *drive.File
meta, driveErr = parentDriveSvc.Files.Get(resp.SpreadsheetId).
SupportsAllDrives(true).
Fields("id, parents").
Context(ctx).
Do()
if driveErr == nil {
moveCall := parentDriveSvc.Files.Update(resp.SpreadsheetId, &drive.File{}).
AddParents(parent).
SupportsAllDrives(true).
Context(ctx)
if len(meta.Parents) > 0 {
moveCall = moveCall.RemoveParents(strings.Join(meta.Parents, ","))
}
_, driveErr = moveCall.Do()
}
}
if driveErr != nil {
moveError = driveErr.Error()
u.Err().Errorf("failed to move spreadsheet to folder: %v", driveErr)
u.Err().Println("Spreadsheet created in Drive root. Move to desired folder if needed.")
} else {
movedToParent = true
}
}
if outfmt.IsJSON(ctx) {
payload := map[string]any{
"spreadsheetId": resp.SpreadsheetId,
"title": resp.Properties.Title,
"spreadsheetUrl": resp.SpreadsheetUrl,
}
if parent != "" {
payload["parent"] = parent
payload["movedToParent"] = movedToParent
if moveError != "" {
payload["moveError"] = moveError
}
}
return outfmt.WriteJSON(ctx, os.Stdout, payload)
}
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
}