Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
22cf640a07 fix: reuse sheets format helpers (#72) (thanks @nilzzzzzz)
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-01-16 09:56:25 +00:00
Nils Czernig
08398cc0f2 feat(sheets): add format command 2026-01-16 09:52:44 +00:00
9 changed files with 471 additions and 19 deletions

View File

@ -22,6 +22,7 @@
- Gmail: `--body-file` for `send`, `drafts create`, and `drafts update` (use `-` for stdin) to send multi-line plain text.
- Drive: `gog drive drives` lists shared drives (Team Drives). (#67) — thanks @pasogott.
- Sheets: `gog sheets format` applies cell formatting via `--format-json` + `--format-fields`. (#72) — thanks @nilzzzzzz.
### Changed

View File

@ -610,6 +610,7 @@ gog slides export <presentationId> --format pdf --out ./deck.pdf
# Sheets
gog sheets copy <spreadsheetId> "My Sheet Copy"
gog sheets export <spreadsheetId> --format pdf --out ./sheet.pdf
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold'
```
### Contacts
@ -679,6 +680,9 @@ gog sheets append <spreadsheetId> 'Sheet1!A:C' 'new|row|data'
gog sheets append <spreadsheetId> 'Sheet1!A:C' 'new|row|data' --copy-validation-from 'Sheet1!A2:C2'
gog sheets clear <spreadsheetId> 'Sheet1!A1:B10'
# Format
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold'
# Create
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
```

View File

@ -28,6 +28,7 @@ type SheetsCmd struct {
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"`

View File

@ -0,0 +1,100 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"google.golang.org/api/sheets/v4"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type SheetsFormatCmd struct {
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A1:B2)"`
FormatJSON string `name:"format-json" help:"Cell format as JSON (Sheets API CellFormat)"`
FormatFields string `name:"format-fields" help:"Format field mask (eg. userEnteredFormat.textFormat.bold)"`
}
func (c *SheetsFormatCmd) 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")
}
if strings.TrimSpace(c.FormatJSON) == "" {
return fmt.Errorf("provide format JSON via --format-json")
}
formatFields := strings.TrimSpace(c.FormatFields)
if formatFields == "" {
return fmt.Errorf("provide format fields via --format-fields")
}
var format sheets.CellFormat
if err := json.Unmarshal([]byte(c.FormatJSON), &format); err != nil {
return fmt.Errorf("invalid format JSON: %w", err)
}
if err := applyForceSendFields(&format, formatFields); err != nil {
return err
}
rangeInfo, err := parseSheetRange(rangeSpec, "format")
if err != nil {
return err
}
svc, err := newSheetsService(ctx, account)
if err != nil {
return err
}
sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID)
if err != nil {
return err
}
gridRange, err := gridRangeFromMap(rangeInfo, sheetIDs, "format")
if err != nil {
return err
}
req := &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{
{
RepeatCell: &sheets.RepeatCellRequest{
Range: gridRange,
Cell: &sheets.CellData{
UserEnteredFormat: &format,
},
Fields: formatFields,
},
},
},
}
if _, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do(); err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"range": rangeSpec,
"fields": formatFields,
})
}
u.Out().Printf("Formatted %s", rangeSpec)
return nil
}

View File

@ -0,0 +1,145 @@
package cmd
import (
"fmt"
"reflect"
"strings"
"google.golang.org/api/sheets/v4"
)
func applyForceSendFields(format *sheets.CellFormat, fieldMask string) error {
if format == nil {
return fmt.Errorf("format is required")
}
for _, raw := range splitFieldMask(fieldMask) {
normalized := normalizeFormatField(raw)
if normalized == "" {
continue
}
if err := forceSendJSONField(format, normalized); err != nil {
return fmt.Errorf("invalid format field %q: %w", strings.TrimSpace(raw), err)
}
}
return nil
}
func splitFieldMask(mask string) []string {
if strings.TrimSpace(mask) == "" {
return nil
}
parts := strings.Split(mask, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func normalizeFormatField(field string) string {
field = strings.TrimSpace(field)
if field == "" {
return ""
}
if field == "userEnteredFormat" {
return ""
}
if strings.HasPrefix(field, "userEnteredFormat.") {
return strings.TrimPrefix(field, "userEnteredFormat.")
}
return ""
}
func forceSendJSONField(root any, jsonPath string) error {
current := reflect.ValueOf(root)
if current.Kind() != reflect.Pointer || current.IsNil() {
return fmt.Errorf("format must be a non-nil pointer")
}
parts := strings.Split(jsonPath, ".")
for i, part := range parts {
if current.Kind() == reflect.Pointer {
if current.IsNil() {
if current.Type().Elem().Kind() != reflect.Struct {
return fmt.Errorf("field %q is not a struct", part)
}
current.Set(reflect.New(current.Type().Elem()))
}
current = current.Elem()
}
if current.Kind() != reflect.Struct {
return fmt.Errorf("field %q is not a struct", part)
}
fieldValue, fieldName, ok := findJSONField(current, part)
if !ok {
return fmt.Errorf("unknown field %q", part)
}
if i == len(parts)-1 {
if fieldValue.Kind() == reflect.Pointer && fieldValue.IsNil() && fieldValue.Type().Elem().Kind() == reflect.Struct {
fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
}
return addForceSendField(current, fieldName)
}
switch fieldValue.Kind() {
case reflect.Pointer:
if fieldValue.IsNil() {
if fieldValue.Type().Elem().Kind() != reflect.Struct {
return fmt.Errorf("field %q is not a struct", part)
}
fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
}
current = fieldValue
case reflect.Struct:
if !fieldValue.CanAddr() {
return fmt.Errorf("field %q is not addressable", part)
}
current = fieldValue.Addr()
default:
return fmt.Errorf("field %q is not a struct", part)
}
}
return nil
}
func findJSONField(v reflect.Value, jsonName string) (reflect.Value, string, bool) {
t := v.Type()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
tag := field.Tag.Get("json")
if tag == "-" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" {
continue
}
if name == jsonName {
return v.Field(i), field.Name, true
}
}
return reflect.Value{}, "", false
}
func addForceSendField(v reflect.Value, fieldName string) error {
fs := v.FieldByName("ForceSendFields")
if !fs.IsValid() {
return fmt.Errorf("missing ForceSendFields")
}
if fs.Kind() != reflect.Slice || fs.Type().Elem().Kind() != reflect.String {
return fmt.Errorf("invalid ForceSendFields")
}
for i := 0; i < fs.Len(); i++ {
if fs.Index(i).String() == fieldName {
return nil
}
}
fs.Set(reflect.Append(fs, reflect.ValueOf(fieldName)))
return nil
}

View File

@ -0,0 +1,36 @@
package cmd
import (
"testing"
"google.golang.org/api/sheets/v4"
)
func TestApplyForceSendFields_TextFormatBold(t *testing.T) {
format := sheets.CellFormat{}
if err := applyForceSendFields(&format, "userEnteredFormat.textFormat.bold"); err != nil {
t.Fatalf("applyForceSendFields: %v", err)
}
if format.TextFormat == nil {
t.Fatalf("expected textFormat to be allocated")
}
if !hasString(format.TextFormat.ForceSendFields, "Bold") {
t.Fatalf("expected Bold to be force-sent, got %#v", format.TextFormat.ForceSendFields)
}
}
func TestApplyForceSendFields_UnknownField(t *testing.T) {
format := sheets.CellFormat{}
if err := applyForceSendFields(&format, "userEnteredFormat.nope"); err == nil {
t.Fatalf("expected error for unknown field")
}
}
func hasString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@ -0,0 +1,106 @@
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
"github.com/steipete/gogcli/internal/ui"
)
func TestSheetsFormatCmd(t *testing.T) {
origNew := newSheetsService
t.Cleanup(func() { newSheetsService = origNew })
var gotRepeat *sheets.RepeatCellRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/sheets/v4")
path = strings.TrimPrefix(path, "/v4")
switch {
case strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"spreadsheetId": "s1",
"sheets": []map[string]any{
{"properties": map[string]any{"sheetId": 42, "title": "Sheet1"}},
},
})
return
case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost:
var req sheets.BatchUpdateSpreadsheetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode batchUpdate: %v", err)
}
if len(req.Requests) != 1 || req.Requests[0].RepeatCell == nil {
t.Fatalf("expected repeatCell request, got %#v", req.Requests)
}
gotRepeat = req.Requests[0].RepeatCell
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{})
return
default:
http.NotFound(w, r)
return
}
}))
defer srv.Close()
svc, err := sheets.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil }
flags := &RootFlags{Account: "a@b.com"}
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
cmd := &SheetsFormatCmd{}
if err := runKong(t, cmd, []string{
"s1",
"Sheet1!B2:C3",
"--format-json", `{"textFormat":{"bold":true}}`,
"--format-fields", "userEnteredFormat.textFormat.bold",
}, ctx, flags); err != nil {
t.Fatalf("format: %v", err)
}
if gotRepeat == nil {
t.Fatal("expected repeatCell request")
}
if gotRepeat.Fields != "userEnteredFormat.textFormat.bold" {
t.Fatalf("unexpected fields: %s", gotRepeat.Fields)
}
if gotRepeat.Range == nil {
t.Fatalf("missing range")
}
if gotRepeat.Range.SheetId != 42 {
t.Fatalf("unexpected sheet id: %d", gotRepeat.Range.SheetId)
}
if gotRepeat.Range.StartRowIndex != 1 || gotRepeat.Range.EndRowIndex != 3 {
t.Fatalf("unexpected row range: %#v", gotRepeat.Range)
}
if gotRepeat.Range.StartColumnIndex != 1 || gotRepeat.Range.EndColumnIndex != 3 {
t.Fatalf("unexpected column range: %#v", gotRepeat.Range)
}
if gotRepeat.Cell == nil || gotRepeat.Cell.UserEnteredFormat == nil || gotRepeat.Cell.UserEnteredFormat.TextFormat == nil {
t.Fatalf("missing format data: %#v", gotRepeat.Cell)
}
if !gotRepeat.Cell.UserEnteredFormat.TextFormat.Bold {
t.Fatalf("expected bold text format, got %#v", gotRepeat.Cell.UserEnteredFormat.TextFormat)
}
}

View File

@ -9,20 +9,13 @@ import (
)
func copyDataValidation(ctx context.Context, svc *sheets.Service, spreadsheetID, sourceA1, destA1 string) error {
sourceRange, err := parseA1Range(sourceA1)
sourceRange, err := parseSheetRange(sourceA1, "copy-validation-from")
if err != nil {
return fmt.Errorf("parse copy-validation-from: %w", err)
return err
}
destRange, err := parseA1Range(destA1)
destRange, err := parseSheetRange(destA1, "updated")
if err != nil {
return fmt.Errorf("parse updated range: %w", err)
}
if strings.TrimSpace(sourceRange.SheetName) == "" {
return fmt.Errorf("copy-validation-from must include a sheet name")
}
if strings.TrimSpace(destRange.SheetName) == "" {
return fmt.Errorf("updated range missing sheet name")
return err
}
sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID)
@ -30,21 +23,21 @@ func copyDataValidation(ctx context.Context, svc *sheets.Service, spreadsheetID,
return err
}
sourceSheetID, ok := sheetIDs[sourceRange.SheetName]
if !ok {
return fmt.Errorf("unknown sheet %q in copy-validation-from", sourceRange.SheetName)
sourceGrid, err := gridRangeFromMap(sourceRange, sheetIDs, "copy-validation-from")
if err != nil {
return err
}
destSheetID, ok := sheetIDs[destRange.SheetName]
if !ok {
return fmt.Errorf("unknown sheet %q in updated range", destRange.SheetName)
destGrid, err := gridRangeFromMap(destRange, sheetIDs, "updated")
if err != nil {
return err
}
req := &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{
{
CopyPaste: &sheets.CopyPasteRequest{
Source: toGridRange(sourceRange, sourceSheetID),
Destination: toGridRange(destRange, destSheetID),
Source: sourceGrid,
Destination: destGrid,
PasteType: "PASTE_DATA_VALIDATION",
},
},
@ -88,3 +81,22 @@ func toGridRange(r a1Range, sheetID int64) *sheets.GridRange {
EndColumnIndex: int64(r.EndCol),
}
}
func parseSheetRange(a1, label string) (a1Range, error) {
r, err := parseA1Range(a1)
if err != nil {
return a1Range{}, fmt.Errorf("parse %s range: %w", label, err)
}
if strings.TrimSpace(r.SheetName) == "" {
return a1Range{}, fmt.Errorf("%s range must include a sheet name", label)
}
return r, nil
}
func gridRangeFromMap(r a1Range, sheetIDs map[string]int64, label string) (*sheets.GridRange, error) {
sheetID, ok := sheetIDs[r.SheetName]
if !ok {
return nil, fmt.Errorf("unknown sheet %q in %s range", r.SheetName, label)
}
return toGridRange(r, sheetID), nil
}

View File

@ -201,3 +201,50 @@ func TestSheetsClearMetadataCreate_ValidationErrors(t *testing.T) {
t.Fatalf("expected create missing title error")
}
}
func TestSheetsFormat_ValidationErrors(t *testing.T) {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
flags := &RootFlags{Account: "a@b.com"}
if err := (&SheetsFormatCmd{}).Run(ctx, flags); err == nil {
t.Fatalf("expected format missing spreadsheetId error")
}
if err := (&SheetsFormatCmd{SpreadsheetID: "s1"}).Run(ctx, flags); err == nil {
t.Fatalf("expected format missing range error")
}
if err := (&SheetsFormatCmd{SpreadsheetID: "s1", Range: "Sheet1!A1"}).Run(ctx, flags); err == nil {
t.Fatalf("expected format missing format-json error")
}
if err := (&SheetsFormatCmd{SpreadsheetID: "s1", Range: "Sheet1!A1", FormatJSON: "{\"textFormat\":{\"bold\":true}}"}).Run(ctx, flags); err == nil {
t.Fatalf("expected format missing format-fields error")
}
if err := (&SheetsFormatCmd{SpreadsheetID: "s1", Range: "Sheet1!A1", FormatJSON: "nope", FormatFields: "userEnteredFormat.textFormat.bold"}).Run(ctx, flags); err == nil {
t.Fatalf("expected format invalid json error")
}
if err := (&SheetsFormatCmd{SpreadsheetID: "s1", Range: "A1:B2", FormatJSON: "{\"textFormat\":{\"bold\":true}}", FormatFields: "userEnteredFormat.textFormat.bold"}).Run(ctx, flags); err == nil {
t.Fatalf("expected format missing sheet name error")
}
}
func TestParseSheetRangeAndGridRange(t *testing.T) {
if _, err := parseSheetRange("A1:B2", "format"); err == nil {
t.Fatalf("expected missing sheet name error")
}
r, err := parseSheetRange("Sheet1!B2:C3", "format")
if err != nil {
t.Fatalf("parseSheetRange: %v", err)
}
grid, err := gridRangeFromMap(r, map[string]int64{"Sheet1": 9}, "format")
if err != nil {
t.Fatalf("gridRangeFromMap: %v", err)
}
if grid.SheetId != 9 {
t.Fatalf("unexpected sheet id: %d", grid.SheetId)
}
}