Skip to content

Commit 3ff145d

Browse files
nilzzzzzzsteipete
authored andcommitted
feat(sheets): add read-format and harden borders formatting
1 parent 45c272f commit 3ff145d

10 files changed

+558
-3
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- Forms: add form update/question-management commands plus response watch create/list/delete/renew, with delete-question validation and confirmation guardrails. (#274) — thanks @alexknowshtml.
2222
- Sheets: add `sheets update-note` / `set-note` to write or clear cell notes across a range. (#430) — thanks @andybergon.
2323
- Sheets: add `sheets create --parent` to place new spreadsheets in a Drive folder. (#424) — thanks @ManManavadaria.
24+
- Sheets: add `sheets read-format` to inspect `userEnteredFormat` / `effectiveFormat` per cell. (#284) — thanks @nilzzzzzz.
2425
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
2526
- Contacts: add `--relation type=person` to contact create/update, include relations in text `contacts get`, and cover relation payload updates. (#351) — thanks @karbassi.
2627
- Contacts: add `--address` to contact create/update and include addresses in text `contacts get`. (#148) — thanks @beezly.
@@ -50,6 +51,7 @@
5051
- Auth: add `--gmail-scope full|readonly`, and disable `include_granted_scopes` for readonly/limited auth requests to avoid Drive/Gmail scope accumulation. (#113) — thanks @salmonumbrella.
5152
- Calendar: force-send `minutes=0` for `--reminder popup:0m` so zero-minute popup reminders survive Google Calendar API JSON omission rules. (#316) — thanks @salmonumbrella.
5253
- Calendar: hide cancelled/deleted events from `calendar events` list output by explicitly setting `showDeleted=false`. (#362) — thanks @sharukh010.
54+
- Sheets: harden `sheets format` against `boarders` typo (JSON and field mask), with clearer error messages. (#284) — thanks @nilzzzzzz.
5355
- Calendar: reject ambiguous calendar-name selectors for `calendar events` instead of guessing. (#131) — thanks @salmonumbrella.
5456
- Calendar: respond patches only attendees to avoid custom reminders validation errors. (#265) — thanks @sebasrodriguez.
5557
- Calendar: clarify that RFC3339 `--from/--to` timestamps must include a timezone while keeping date and relative-time help intact. (#409) — thanks @dbhurley.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,8 @@ gog slides replace-slide <presentationId> <slideId> ./new-slide.png --notes "New
928928
gog sheets copy <spreadsheetId> "My Sheet Copy"
929929
gog sheets export <spreadsheetId> --format pdf --out ./sheet.pdf
930930
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold'
931+
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"borders":{"top":{"style":"SOLID"}}}' --format-fields 'userEnteredFormat.borders.top.style'
932+
gog sheets read-format <spreadsheetId> 'Sheet1!A1:B2'
931933
gog sheets insert <spreadsheetId> "Sheet1" rows 2 --count 3
932934
gog sheets notes <spreadsheetId> 'Sheet1!A1:B10'
933935
gog sheets links <spreadsheetId> 'Sheet1!A1:B10'
@@ -1028,6 +1030,9 @@ gog sheets clear <spreadsheetId> MyNamedRange
10281030
# Format
10291031
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold'
10301032
gog sheets format <spreadsheetId> MyNamedRange --format-json '{"textFormat":{"bold":true}}' --format-fields 'userEnteredFormat.textFormat.bold'
1033+
gog sheets format <spreadsheetId> 'Sheet1!A1:B2' --format-json '{"borders":{"top":{"style":"SOLID"}}}' --format-fields 'userEnteredFormat.borders.top.style'
1034+
gog sheets read-format <spreadsheetId> 'Sheet1!A1:B2'
1035+
gog sheets read-format <spreadsheetId> 'Sheet1!A1:B2' --effective
10311036

10321037
# Named ranges
10331038
gog sheets named-ranges <spreadsheetId>

internal/cmd/execute_sheets_more_commands_test.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,27 @@ func TestExecute_SheetsMoreCommands(t *testing.T) {
5858
"spreadsheetId": "id1",
5959
"properties": map[string]any{"title": "T"},
6060
"sheets": []map[string]any{
61-
{"properties": map[string]any{"sheetId": 0, "title": "Sheet1"}},
61+
{
62+
"properties": map[string]any{"sheetId": 0, "title": "Sheet1"},
63+
"data": []map[string]any{
64+
{
65+
"startRow": 0,
66+
"startColumn": 0,
67+
"rowData": []map[string]any{
68+
{
69+
"values": []map[string]any{
70+
{
71+
"formattedValue": "a",
72+
"userEnteredFormat": map[string]any{
73+
"textFormat": map[string]any{"bold": true},
74+
},
75+
},
76+
},
77+
},
78+
},
79+
},
80+
},
81+
},
6282
},
6383
})
6484
return
@@ -138,6 +158,11 @@ func TestExecute_SheetsMoreCommands(t *testing.T) {
138158
t.Fatalf("metadata: %v", err)
139159
}
140160
})
161+
_ = captureStdout(t, func() {
162+
if err := Execute([]string{"--json", "sheets", "read-format", "id1", "Sheet1!A1:A1"}); err != nil {
163+
t.Fatalf("read-format: %v", err)
164+
}
165+
})
141166
_ = captureStdout(t, func() {
142167
if err := Execute([]string{"--json", "sheets", "create", "New", "--sheets", "Income,Expenses"}); err != nil {
143168
t.Fatalf("create: %v", err)

internal/cmd/sheets.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type SheetsCmd struct {
3030
Insert SheetsInsertCmd `cmd:"" name:"insert" help:"Insert empty rows or columns into a sheet"`
3131
Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"`
3232
Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"`
33+
ReadFormat SheetsReadFormatCmd `cmd:"" name:"read-format" aliases:"get-format,format-read" help:"Read cell formatting from a range"`
3334
Notes SheetsNotesCmd `cmd:"" name:"notes" help:"Get cell notes from a range"`
3435
UpdateNote SheetsUpdateNoteCmd `cmd:"" name:"update-note" aliases:"set-note" help:"Set or clear a cell note"`
3536
Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"`

internal/cmd/sheets_format.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"fmt"
8+
"io"
79
"os"
810
"strings"
911

@@ -38,13 +40,17 @@ func (c *SheetsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
3840
return fmt.Errorf("provide format fields via --format-fields")
3941
}
4042

43+
if hasBoardersTypo(formatFields) {
44+
return fmt.Errorf(`invalid --format-fields: found "boarders"; use "borders"`)
45+
}
46+
4147
var err error
4248
var format sheets.CellFormat
4349
b, err := resolveInlineOrFileBytes(c.FormatJSON)
4450
if err != nil {
4551
return fmt.Errorf("read --format-json: %w", err)
4652
}
47-
if err = json.Unmarshal(b, &format); err != nil {
53+
if err = decodeCellFormatJSON(b, &format); err != nil {
4854
return fmt.Errorf("invalid format JSON: %w", err)
4955
}
5056

@@ -112,3 +118,35 @@ func (c *SheetsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
112118
u.Out().Printf("Formatted %s", rangeSpec)
113119
return nil
114120
}
121+
122+
func decodeCellFormatJSON(data []byte, dst *sheets.CellFormat) error {
123+
if dst == nil {
124+
return fmt.Errorf("format is required")
125+
}
126+
127+
dec := json.NewDecoder(bytes.NewReader(data))
128+
dec.DisallowUnknownFields()
129+
130+
if err := dec.Decode(dst); err != nil {
131+
return err
132+
}
133+
var extra any
134+
if err := dec.Decode(&extra); err != io.EOF {
135+
if err == nil {
136+
return fmt.Errorf("multiple JSON values")
137+
}
138+
return err
139+
}
140+
return nil
141+
}
142+
143+
func hasBoardersTypo(mask string) bool {
144+
for _, part := range splitFieldMask(mask) {
145+
for _, token := range strings.Split(part, ".") {
146+
if strings.EqualFold(strings.TrimSpace(token), "boarders") {
147+
return true
148+
}
149+
}
150+
}
151+
return false
152+
}

internal/cmd/sheets_format_fields_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ func TestApplyForceSendFields_NumberFormatType(t *testing.T) {
3939
}
4040
}
4141

42+
func TestApplyForceSendFields_BordersTopStyle(t *testing.T) {
43+
format := sheets.CellFormat{}
44+
if err := applyForceSendFields(&format, []string{"borders.top.style"}); err != nil {
45+
t.Fatalf("applyForceSendFields: %v", err)
46+
}
47+
if format.Borders == nil || format.Borders.Top == nil {
48+
t.Fatalf("expected borders.top to be allocated, got %#v", format.Borders)
49+
}
50+
if !hasString(format.Borders.Top.ForceSendFields, "Style") {
51+
t.Fatalf("expected Style to be force-sent, got %#v", format.Borders.Top.ForceSendFields)
52+
}
53+
}
54+
4255
func TestApplyForceSendFields_NilFormat(t *testing.T) {
4356
if err := applyForceSendFields(nil, []string{"textFormat.bold"}); err == nil {
4457
t.Fatalf("expected error for nil format")
@@ -83,3 +96,15 @@ func TestNormalizeFormatMask_LeavesUnknowns(t *testing.T) {
8396
t.Fatalf("unexpected format paths: %#v", paths)
8497
}
8598
}
99+
100+
func TestHasBoardersTypo(t *testing.T) {
101+
if !hasBoardersTypo("boarders.top.style") {
102+
t.Fatalf("expected typo detection for boarders")
103+
}
104+
if !hasBoardersTypo("userEnteredFormat.boarders.top.style") {
105+
t.Fatalf("expected typo detection for userEnteredFormat.boarders")
106+
}
107+
if hasBoardersTypo("borders.top.style") {
108+
t.Fatalf("did not expect typo detection for borders")
109+
}
110+
}

internal/cmd/sheets_format_test.go

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ func TestSheetsFormatCmd(t *testing.T) {
104104
t.Fatalf("expected bold text format, got %#v", gotRepeat.Cell.UserEnteredFormat.TextFormat)
105105
}
106106
}
107-
108107
func TestSheetsFormatCmdNamedRange(t *testing.T) {
109108
origNew := newSheetsService
110109
t.Cleanup(func() { newSheetsService = origNew })
@@ -195,3 +194,81 @@ func TestSheetsFormatCmdNamedRange(t *testing.T) {
195194
t.Fatalf("unexpected column range: %#v", gotRepeat.Range)
196195
}
197196
}
197+
198+
func TestSheetsFormatCmd_BordersTopStyle(t *testing.T) {
199+
origNew := newSheetsService
200+
t.Cleanup(func() { newSheetsService = origNew })
201+
202+
var gotRepeat *sheets.RepeatCellRequest
203+
204+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205+
path := strings.TrimPrefix(r.URL.Path, "/sheets/v4")
206+
path = strings.TrimPrefix(path, "/v4")
207+
switch {
208+
case strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet:
209+
w.Header().Set("Content-Type", "application/json")
210+
_ = json.NewEncoder(w).Encode(map[string]any{
211+
"spreadsheetId": "s1",
212+
"sheets": []map[string]any{
213+
{"properties": map[string]any{"sheetId": 42, "title": "Sheet1"}},
214+
},
215+
})
216+
return
217+
case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost:
218+
var req sheets.BatchUpdateSpreadsheetRequest
219+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
220+
t.Fatalf("decode batchUpdate: %v", err)
221+
}
222+
if len(req.Requests) != 1 || req.Requests[0].RepeatCell == nil {
223+
t.Fatalf("expected repeatCell request, got %#v", req.Requests)
224+
}
225+
gotRepeat = req.Requests[0].RepeatCell
226+
w.Header().Set("Content-Type", "application/json")
227+
_ = json.NewEncoder(w).Encode(map[string]any{})
228+
return
229+
default:
230+
http.NotFound(w, r)
231+
return
232+
}
233+
}))
234+
defer srv.Close()
235+
236+
svc, err := sheets.NewService(context.Background(),
237+
option.WithoutAuthentication(),
238+
option.WithHTTPClient(srv.Client()),
239+
option.WithEndpoint(srv.URL+"/"),
240+
)
241+
if err != nil {
242+
t.Fatalf("NewService: %v", err)
243+
}
244+
newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil }
245+
246+
flags := &RootFlags{Account: "a@b.com"}
247+
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
248+
if uiErr != nil {
249+
t.Fatalf("ui.New: %v", uiErr)
250+
}
251+
ctx := ui.WithUI(context.Background(), u)
252+
cmd := &SheetsFormatCmd{}
253+
if err := runKong(t, cmd, []string{
254+
"s1",
255+
"Sheet1!B2:C3",
256+
"--format-json", `{"borders":{"top":{"style":"SOLID"}}}`,
257+
"--format-fields", "borders.top.style",
258+
}, ctx, flags); err != nil {
259+
t.Fatalf("format: %v", err)
260+
}
261+
262+
if gotRepeat == nil {
263+
t.Fatal("expected repeatCell request")
264+
}
265+
if gotRepeat.Fields != "userEnteredFormat.borders.top.style" {
266+
t.Fatalf("unexpected fields: %s", gotRepeat.Fields)
267+
}
268+
if gotRepeat.Cell == nil || gotRepeat.Cell.UserEnteredFormat == nil || gotRepeat.Cell.UserEnteredFormat.Borders == nil || gotRepeat.Cell.UserEnteredFormat.Borders.Top == nil {
269+
t.Fatalf("missing border data: %#v", gotRepeat.Cell)
270+
}
271+
if gotRepeat.Cell.UserEnteredFormat.Borders.Top.Style != "SOLID" {
272+
t.Fatalf("expected SOLID top border, got %#v", gotRepeat.Cell.UserEnteredFormat.Borders.Top)
273+
}
274+
}

0 commit comments

Comments
 (0)