Compare commits
2 Commits
main
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22cf640a07 | ||
|
|
08398cc0f2 |
@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
- Gmail: `--body-file` for `send`, `drafts create`, and `drafts update` (use `-` for stdin) to send multi-line plain text.
|
- 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.
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@ -610,6 +610,7 @@ gog slides export <presentationId> --format pdf --out ./deck.pdf
|
|||||||
# Sheets
|
# Sheets
|
||||||
gog sheets copy <spreadsheetId> "My Sheet Copy"
|
gog sheets copy <spreadsheetId> "My Sheet Copy"
|
||||||
gog sheets export <spreadsheetId> --format pdf --out ./sheet.pdf
|
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
|
### 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 append <spreadsheetId> 'Sheet1!A:C' 'new|row|data' --copy-validation-from 'Sheet1!A2:C2'
|
||||||
gog sheets clear <spreadsheetId> 'Sheet1!A1:B10'
|
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
|
# Create
|
||||||
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
|
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
|
||||||
```
|
```
|
||||||
|
|||||||
@ -28,6 +28,7 @@ type SheetsCmd struct {
|
|||||||
Update SheetsUpdateCmd `cmd:"" name:"update" help:"Update values in a range"`
|
Update SheetsUpdateCmd `cmd:"" name:"update" help:"Update values in a range"`
|
||||||
Append SheetsAppendCmd `cmd:"" name:"append" help:"Append values to a range"`
|
Append SheetsAppendCmd `cmd:"" name:"append" help:"Append values to a range"`
|
||||||
Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in 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"`
|
Metadata SheetsMetadataCmd `cmd:"" name:"metadata" help:"Get spreadsheet metadata"`
|
||||||
Create SheetsCreateCmd `cmd:"" name:"create" help:"Create a new spreadsheet"`
|
Create SheetsCreateCmd `cmd:"" name:"create" help:"Create a new spreadsheet"`
|
||||||
Copy SheetsCopyCmd `cmd:"" name:"copy" help:"Copy a Google Sheet"`
|
Copy SheetsCopyCmd `cmd:"" name:"copy" help:"Copy a Google Sheet"`
|
||||||
|
|||||||
100
internal/cmd/sheets_format.go
Normal file
100
internal/cmd/sheets_format.go
Normal 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
|
||||||
|
}
|
||||||
145
internal/cmd/sheets_format_fields.go
Normal file
145
internal/cmd/sheets_format_fields.go
Normal 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
|
||||||
|
}
|
||||||
36
internal/cmd/sheets_format_fields_test.go
Normal file
36
internal/cmd/sheets_format_fields_test.go
Normal 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
|
||||||
|
}
|
||||||
106
internal/cmd/sheets_format_test.go
Normal file
106
internal/cmd/sheets_format_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,20 +9,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func copyDataValidation(ctx context.Context, svc *sheets.Service, spreadsheetID, sourceA1, destA1 string) error {
|
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 {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("parse updated range: %w", err)
|
return 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID)
|
sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID)
|
||||||
@ -30,21 +23,21 @@ func copyDataValidation(ctx context.Context, svc *sheets.Service, spreadsheetID,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSheetID, ok := sheetIDs[sourceRange.SheetName]
|
sourceGrid, err := gridRangeFromMap(sourceRange, sheetIDs, "copy-validation-from")
|
||||||
if !ok {
|
if err != nil {
|
||||||
return fmt.Errorf("unknown sheet %q in copy-validation-from", sourceRange.SheetName)
|
return err
|
||||||
}
|
}
|
||||||
destSheetID, ok := sheetIDs[destRange.SheetName]
|
destGrid, err := gridRangeFromMap(destRange, sheetIDs, "updated")
|
||||||
if !ok {
|
if err != nil {
|
||||||
return fmt.Errorf("unknown sheet %q in updated range", destRange.SheetName)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &sheets.BatchUpdateSpreadsheetRequest{
|
req := &sheets.BatchUpdateSpreadsheetRequest{
|
||||||
Requests: []*sheets.Request{
|
Requests: []*sheets.Request{
|
||||||
{
|
{
|
||||||
CopyPaste: &sheets.CopyPasteRequest{
|
CopyPaste: &sheets.CopyPasteRequest{
|
||||||
Source: toGridRange(sourceRange, sourceSheetID),
|
Source: sourceGrid,
|
||||||
Destination: toGridRange(destRange, destSheetID),
|
Destination: destGrid,
|
||||||
PasteType: "PASTE_DATA_VALIDATION",
|
PasteType: "PASTE_DATA_VALIDATION",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -88,3 +81,22 @@ func toGridRange(r a1Range, sheetID int64) *sheets.GridRange {
|
|||||||
EndColumnIndex: int64(r.EndCol),
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -201,3 +201,50 @@ func TestSheetsClearMetadataCreate_ValidationErrors(t *testing.T) {
|
|||||||
t.Fatalf("expected create missing title error")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user