Skip to content

Commit 47c49fb

Browse files
committed
fix: reuse sheets format helpers (#72) (thanks @nilzzzzzz)
1 parent 4105be8 commit 47c49fb

6 files changed

Lines changed: 240 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

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

2627
### Changed
2728

@@ -32,7 +33,6 @@
3233
### Added
3334

3435
- Auth: Workspace service accounts (domain-wide delegation) for all services via `gog auth service-account ...` (preferred when configured). (#54) — thanks @pvieito.
35-
- Sheets: `gog sheets format` applies cell formatting via `--format-json` + `--format-fields`.
3636

3737
### Fixed
3838

internal/cmd/sheets_format.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ func (c *SheetsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
4747
if err := json.Unmarshal([]byte(c.FormatJSON), &format); err != nil {
4848
return fmt.Errorf("invalid format JSON: %w", err)
4949
}
50+
if err := applyForceSendFields(&format, formatFields); err != nil {
51+
return err
52+
}
5053

51-
rangeInfo, err := parseA1Range(rangeSpec)
54+
rangeInfo, err := parseSheetRange(rangeSpec, "format")
5255
if err != nil {
5356
return err
5457
}
55-
if strings.TrimSpace(rangeInfo.SheetName) == "" {
56-
return fmt.Errorf("format range must include a sheet name")
57-
}
5858

5959
svc, err := newSheetsService(ctx, account)
6060
if err != nil {
@@ -65,16 +65,16 @@ func (c *SheetsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
6565
if err != nil {
6666
return err
6767
}
68-
sheetID, ok := sheetIDs[rangeInfo.SheetName]
69-
if !ok {
70-
return fmt.Errorf("unknown sheet %q in format range", rangeInfo.SheetName)
68+
gridRange, err := gridRangeFromMap(rangeInfo, sheetIDs, "format")
69+
if err != nil {
70+
return err
7171
}
7272

7373
req := &sheets.BatchUpdateSpreadsheetRequest{
7474
Requests: []*sheets.Request{
7575
{
7676
RepeatCell: &sheets.RepeatCellRequest{
77-
Range: toGridRange(rangeInfo, sheetID),
77+
Range: gridRange,
7878
Cell: &sheets.CellData{
7979
UserEnteredFormat: &format,
8080
},
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"strings"
7+
8+
"google.golang.org/api/sheets/v4"
9+
)
10+
11+
func applyForceSendFields(format *sheets.CellFormat, fieldMask string) error {
12+
if format == nil {
13+
return fmt.Errorf("format is required")
14+
}
15+
16+
for _, raw := range splitFieldMask(fieldMask) {
17+
normalized := normalizeFormatField(raw)
18+
if normalized == "" {
19+
continue
20+
}
21+
if err := forceSendJSONField(format, normalized); err != nil {
22+
return fmt.Errorf("invalid format field %q: %w", strings.TrimSpace(raw), err)
23+
}
24+
}
25+
return nil
26+
}
27+
28+
func splitFieldMask(mask string) []string {
29+
if strings.TrimSpace(mask) == "" {
30+
return nil
31+
}
32+
parts := strings.Split(mask, ",")
33+
for i := range parts {
34+
parts[i] = strings.TrimSpace(parts[i])
35+
}
36+
return parts
37+
}
38+
39+
func normalizeFormatField(field string) string {
40+
field = strings.TrimSpace(field)
41+
if field == "" {
42+
return ""
43+
}
44+
if field == "userEnteredFormat" {
45+
return ""
46+
}
47+
if strings.HasPrefix(field, "userEnteredFormat.") {
48+
return strings.TrimPrefix(field, "userEnteredFormat.")
49+
}
50+
return ""
51+
}
52+
53+
func forceSendJSONField(root any, jsonPath string) error {
54+
current := reflect.ValueOf(root)
55+
if current.Kind() != reflect.Pointer || current.IsNil() {
56+
return fmt.Errorf("format must be a non-nil pointer")
57+
}
58+
59+
parts := strings.Split(jsonPath, ".")
60+
for i, part := range parts {
61+
if current.Kind() == reflect.Pointer {
62+
if current.IsNil() {
63+
if current.Type().Elem().Kind() != reflect.Struct {
64+
return fmt.Errorf("field %q is not a struct", part)
65+
}
66+
current.Set(reflect.New(current.Type().Elem()))
67+
}
68+
current = current.Elem()
69+
}
70+
if current.Kind() != reflect.Struct {
71+
return fmt.Errorf("field %q is not a struct", part)
72+
}
73+
74+
fieldValue, fieldName, ok := findJSONField(current, part)
75+
if !ok {
76+
return fmt.Errorf("unknown field %q", part)
77+
}
78+
79+
if i == len(parts)-1 {
80+
if fieldValue.Kind() == reflect.Pointer && fieldValue.IsNil() && fieldValue.Type().Elem().Kind() == reflect.Struct {
81+
fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
82+
}
83+
return addForceSendField(current, fieldName)
84+
}
85+
86+
switch fieldValue.Kind() {
87+
case reflect.Pointer:
88+
if fieldValue.IsNil() {
89+
if fieldValue.Type().Elem().Kind() != reflect.Struct {
90+
return fmt.Errorf("field %q is not a struct", part)
91+
}
92+
fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
93+
}
94+
current = fieldValue
95+
case reflect.Struct:
96+
if !fieldValue.CanAddr() {
97+
return fmt.Errorf("field %q is not addressable", part)
98+
}
99+
current = fieldValue.Addr()
100+
default:
101+
return fmt.Errorf("field %q is not a struct", part)
102+
}
103+
}
104+
105+
return nil
106+
}
107+
108+
func findJSONField(v reflect.Value, jsonName string) (reflect.Value, string, bool) {
109+
t := v.Type()
110+
for i := 0; i < t.NumField(); i++ {
111+
field := t.Field(i)
112+
if field.PkgPath != "" {
113+
continue
114+
}
115+
tag := field.Tag.Get("json")
116+
if tag == "-" {
117+
continue
118+
}
119+
name := strings.Split(tag, ",")[0]
120+
if name == "" {
121+
continue
122+
}
123+
if name == jsonName {
124+
return v.Field(i), field.Name, true
125+
}
126+
}
127+
return reflect.Value{}, "", false
128+
}
129+
130+
func addForceSendField(v reflect.Value, fieldName string) error {
131+
fs := v.FieldByName("ForceSendFields")
132+
if !fs.IsValid() {
133+
return fmt.Errorf("missing ForceSendFields")
134+
}
135+
if fs.Kind() != reflect.Slice || fs.Type().Elem().Kind() != reflect.String {
136+
return fmt.Errorf("invalid ForceSendFields")
137+
}
138+
for i := 0; i < fs.Len(); i++ {
139+
if fs.Index(i).String() == fieldName {
140+
return nil
141+
}
142+
}
143+
fs.Set(reflect.Append(fs, reflect.ValueOf(fieldName)))
144+
return nil
145+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"google.golang.org/api/sheets/v4"
7+
)
8+
9+
func TestApplyForceSendFields_TextFormatBold(t *testing.T) {
10+
format := sheets.CellFormat{}
11+
if err := applyForceSendFields(&format, "userEnteredFormat.textFormat.bold"); err != nil {
12+
t.Fatalf("applyForceSendFields: %v", err)
13+
}
14+
if format.TextFormat == nil {
15+
t.Fatalf("expected textFormat to be allocated")
16+
}
17+
if !hasString(format.TextFormat.ForceSendFields, "Bold") {
18+
t.Fatalf("expected Bold to be force-sent, got %#v", format.TextFormat.ForceSendFields)
19+
}
20+
}
21+
22+
func TestApplyForceSendFields_UnknownField(t *testing.T) {
23+
format := sheets.CellFormat{}
24+
if err := applyForceSendFields(&format, "userEnteredFormat.nope"); err == nil {
25+
t.Fatalf("expected error for unknown field")
26+
}
27+
}
28+
29+
func hasString(values []string, target string) bool {
30+
for _, value := range values {
31+
if value == target {
32+
return true
33+
}
34+
}
35+
return false
36+
}

internal/cmd/sheets_validation.go

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,35 @@ import (
99
)
1010

1111
func copyDataValidation(ctx context.Context, svc *sheets.Service, spreadsheetID, sourceA1, destA1 string) error {
12-
sourceRange, err := parseA1Range(sourceA1)
12+
sourceRange, err := parseSheetRange(sourceA1, "copy-validation-from")
1313
if err != nil {
14-
return fmt.Errorf("parse copy-validation-from: %w", err)
14+
return err
1515
}
16-
destRange, err := parseA1Range(destA1)
16+
destRange, err := parseSheetRange(destA1, "updated")
1717
if err != nil {
18-
return fmt.Errorf("parse updated range: %w", err)
19-
}
20-
21-
if strings.TrimSpace(sourceRange.SheetName) == "" {
22-
return fmt.Errorf("copy-validation-from must include a sheet name")
23-
}
24-
if strings.TrimSpace(destRange.SheetName) == "" {
25-
return fmt.Errorf("updated range missing sheet name")
18+
return err
2619
}
2720

2821
sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID)
2922
if err != nil {
3023
return err
3124
}
3225

33-
sourceSheetID, ok := sheetIDs[sourceRange.SheetName]
34-
if !ok {
35-
return fmt.Errorf("unknown sheet %q in copy-validation-from", sourceRange.SheetName)
26+
sourceGrid, err := gridRangeFromMap(sourceRange, sheetIDs, "copy-validation-from")
27+
if err != nil {
28+
return err
3629
}
37-
destSheetID, ok := sheetIDs[destRange.SheetName]
38-
if !ok {
39-
return fmt.Errorf("unknown sheet %q in updated range", destRange.SheetName)
30+
destGrid, err := gridRangeFromMap(destRange, sheetIDs, "updated")
31+
if err != nil {
32+
return err
4033
}
4134

4235
req := &sheets.BatchUpdateSpreadsheetRequest{
4336
Requests: []*sheets.Request{
4437
{
4538
CopyPaste: &sheets.CopyPasteRequest{
46-
Source: toGridRange(sourceRange, sourceSheetID),
47-
Destination: toGridRange(destRange, destSheetID),
39+
Source: sourceGrid,
40+
Destination: destGrid,
4841
PasteType: "PASTE_DATA_VALIDATION",
4942
},
5043
},
@@ -88,3 +81,22 @@ func toGridRange(r a1Range, sheetID int64) *sheets.GridRange {
8881
EndColumnIndex: int64(r.EndCol),
8982
}
9083
}
84+
85+
func parseSheetRange(a1, label string) (a1Range, error) {
86+
r, err := parseA1Range(a1)
87+
if err != nil {
88+
return a1Range{}, fmt.Errorf("parse %s range: %w", label, err)
89+
}
90+
if strings.TrimSpace(r.SheetName) == "" {
91+
return a1Range{}, fmt.Errorf("%s range must include a sheet name", label)
92+
}
93+
return r, nil
94+
}
95+
96+
func gridRangeFromMap(r a1Range, sheetIDs map[string]int64, label string) (*sheets.GridRange, error) {
97+
sheetID, ok := sheetIDs[r.SheetName]
98+
if !ok {
99+
return nil, fmt.Errorf("unknown sheet %q in %s range", r.SheetName, label)
100+
}
101+
return toGridRange(r, sheetID), nil
102+
}

internal/cmd/sheets_validation_more_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,22 @@ func TestSheetsFormat_ValidationErrors(t *testing.T) {
229229
t.Fatalf("expected format missing sheet name error")
230230
}
231231
}
232+
233+
func TestParseSheetRangeAndGridRange(t *testing.T) {
234+
if _, err := parseSheetRange("A1:B2", "format"); err == nil {
235+
t.Fatalf("expected missing sheet name error")
236+
}
237+
238+
r, err := parseSheetRange("Sheet1!B2:C3", "format")
239+
if err != nil {
240+
t.Fatalf("parseSheetRange: %v", err)
241+
}
242+
243+
grid, err := gridRangeFromMap(r, map[string]int64{"Sheet1": 9}, "format")
244+
if err != nil {
245+
t.Fatalf("gridRangeFromMap: %v", err)
246+
}
247+
if grid.SheetId != 9 {
248+
t.Fatalf("unexpected sheet id: %d", grid.SheetId)
249+
}
250+
}

0 commit comments

Comments
 (0)