feat(sheets): append table rows
Adds table-aware row appends for Google Sheets tables, including docs, generated command page, width validation, and live Google smoke verification.
This commit is contained in:
parent
322695f78c
commit
5d3b1c0980
@ -14,6 +14,7 @@
|
||||
- Calendar: add `calendar move` / `calendar transfer` to move an event to another calendar and change its organizer. (#448) — thanks @markusbkoch.
|
||||
- Docs: add `docs add-tab`, `docs rename-tab`, and `docs delete-tab` for managing Google Docs tabs. (#547) — thanks @chopenhauer.
|
||||
- Docs: support tab-scoped Markdown append and find-replace flows. (#541) — thanks @donbowman.
|
||||
- Sheets: add `sheets table append` for appending rows to structured Sheets tables without targeting headers directly.
|
||||
|
||||
### Fixed
|
||||
- Agent safety: compile baked safety profile policies into generated hash switches so raw allow/deny rule strings are not embedded as patchable YAML. (#540) — thanks @drewburchfield.
|
||||
|
||||
@ -1391,6 +1391,7 @@ gog sheets named-ranges delete <spreadsheetId> MyNamedRange2
|
||||
# Tables
|
||||
gog sheets table list <spreadsheetId>
|
||||
gog sheets table create <spreadsheetId> 'Sheet1!A1:C4' --name Tasks --columns-json '[{"columnName":"Task","columnType":"TEXT"},{"columnName":"Amount","columnType":"DOUBLE"},{"columnName":"Done","columnType":"BOOLEAN"}]'
|
||||
gog sheets table append <spreadsheetId> <tableId> --values-json '[["Write docs",2,true]]'
|
||||
gog sheets table get <spreadsheetId> <tableId>
|
||||
gog sheets table delete <spreadsheetId> <tableId> --force
|
||||
# See docs/sheets-tables.md for valid column types and current command scope.
|
||||
|
||||
@ -415,6 +415,7 @@ Generated from `gog schema --json`.
|
||||
- [`gog sheets (sheet) resize-columns <spreadsheetId> <columns> [flags]`](commands/gog-sheets-resize-columns.md) - Resize sheet columns
|
||||
- [`gog sheets (sheet) resize-rows <spreadsheetId> <rows> [flags]`](commands/gog-sheets-resize-rows.md) - Resize sheet rows
|
||||
- [`gog sheets (sheet) table (tables) <command>`](commands/gog-sheets-table.md) - Manage Google Sheets tables
|
||||
- [`gog sheets (sheet) table (tables) append (add-row,add-rows) <spreadsheetId> <tableId> [<values> ...] [flags]`](commands/gog-sheets-table-append.md) - Append rows to a table
|
||||
- [`gog sheets (sheet) table (tables) create (add,new) --name=STRING --columns-json=STRING <spreadsheetId> <range>`](commands/gog-sheets-table-create.md) - Create a table
|
||||
- [`gog sheets (sheet) table (tables) delete (rm,remove,del) <spreadsheetId> <tableId>`](commands/gog-sheets-table-delete.md) - Delete a table
|
||||
- [`gog sheets (sheet) table (tables) get (show,info) <spreadsheetId> <tableId>`](commands/gog-sheets-table-get.md) - Get a table
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments.
|
||||
|
||||
Generated pages: 456.
|
||||
Generated pages: 457.
|
||||
|
||||
## Top-level Commands
|
||||
|
||||
@ -458,6 +458,7 @@ Generated pages: 456.
|
||||
- [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns
|
||||
- [gog sheets resize-rows](gog-sheets-resize-rows.md) - Resize sheet rows
|
||||
- [gog sheets table](gog-sheets-table.md) - Manage Google Sheets tables
|
||||
- [gog sheets table append](gog-sheets-table-append.md) - Append rows to a table
|
||||
- [gog sheets table create](gog-sheets-table-create.md) - Create a table
|
||||
- [gog sheets table delete](gog-sheets-table-delete.md) - Delete a table
|
||||
- [gog sheets table get](gog-sheets-table-get.md) - Get a table
|
||||
|
||||
44
docs/commands/gog-sheets-table-append.md
Normal file
44
docs/commands/gog-sheets-table-append.md
Normal file
@ -0,0 +1,44 @@
|
||||
# `gog sheets table append`
|
||||
|
||||
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
|
||||
|
||||
Append rows to a table
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gog sheets (sheet) table (tables) append (add-row,add-rows) <spreadsheetId> <tableId> [<values> ...] [flags]
|
||||
```
|
||||
|
||||
## Parent
|
||||
|
||||
- [gog sheets table](gog-sheets-table.md)
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Type | Default | Help |
|
||||
| --- | --- | --- | --- |
|
||||
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
|
||||
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) |
|
||||
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
|
||||
| `--color` | `string` | auto | Color output: auto\|always\|never |
|
||||
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
|
||||
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
|
||||
| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) |
|
||||
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
|
||||
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
|
||||
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
|
||||
| `--input` | `string` | USER_ENTERED | Value input option: RAW or USER_ENTERED |
|
||||
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
|
||||
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
|
||||
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
|
||||
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
|
||||
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
|
||||
| `--values-json` | `string` | | Values as JSON 2D array |
|
||||
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
|
||||
| `--version` | `kong.VersionFlag` | | Print version and exit |
|
||||
|
||||
## See Also
|
||||
|
||||
- [gog sheets table](gog-sheets-table.md)
|
||||
- [Command index](README.md)
|
||||
@ -16,6 +16,7 @@ gog sheets (sheet) table (tables) <command>
|
||||
|
||||
## Subcommands
|
||||
|
||||
- [gog sheets table append](gog-sheets-table-append.md) - Append rows to a table
|
||||
- [gog sheets table create](gog-sheets-table-create.md) - Create a table
|
||||
- [gog sheets table delete](gog-sheets-table-delete.md) - Delete a table
|
||||
- [gog sheets table get](gog-sheets-table-get.md) - Get a table
|
||||
|
||||
@ -78,6 +78,29 @@ gog sheets table get "$spreadsheet_id" Tasks --json
|
||||
JSON output includes the table ID, table name, sheet title, A1 range, raw
|
||||
`GridRange`, and typed columns.
|
||||
|
||||
## Append Rows
|
||||
|
||||
Append rows by table ID or name:
|
||||
|
||||
```bash
|
||||
gog sheets table append "$spreadsheet_id" "$table_id" \
|
||||
--values-json '[["Write docs",2,true]]'
|
||||
```
|
||||
|
||||
Positional values use the same comma-separated row, pipe-separated cell syntax
|
||||
as `gog sheets append`:
|
||||
|
||||
```bash
|
||||
gog sheets table append "$spreadsheet_id" Tasks 'Write docs|2|true'
|
||||
gog sheets table append "$spreadsheet_id" Tasks 'One|1|false,Two|2|true'
|
||||
```
|
||||
|
||||
`sheets table append` resolves the table first, then calls the Sheets append API
|
||||
against the table's bounded A1 range with `INSERT_ROWS`. This lets Sheets place
|
||||
new rows after the current table data and expand the table, without targeting
|
||||
the header row directly. Rows wider than the table's column count are rejected
|
||||
before the mutation is sent.
|
||||
|
||||
## Delete A Table
|
||||
|
||||
Deleting removes the table object. Use `--force` for non-interactive runs:
|
||||
@ -94,10 +117,10 @@ gog sheets table delete "$spreadsheet_id" "$table_id" --dry-run --json
|
||||
|
||||
## Current Scope
|
||||
|
||||
This first table command set intentionally covers list, get, create, and delete
|
||||
only. Row append, table update, footer handling, and table-aware clear behavior
|
||||
need separate semantics because the plain Sheets range APIs can touch table
|
||||
headers or footer rows if used blindly.
|
||||
This table command set intentionally covers list, get, create, append, and
|
||||
delete. Table update, footer editing, and table-aware clear behavior need
|
||||
separate semantics because the plain Sheets range APIs can touch table headers
|
||||
or footer rows if used blindly.
|
||||
|
||||
## Command Pages
|
||||
|
||||
@ -105,4 +128,5 @@ headers or footer rows if used blindly.
|
||||
- [`gog sheets table list`](commands/gog-sheets-table-list.md)
|
||||
- [`gog sheets table get`](commands/gog-sheets-table-get.md)
|
||||
- [`gog sheets table create`](commands/gog-sheets-table-create.md)
|
||||
- [`gog sheets table append`](commands/gog-sheets-table-append.md)
|
||||
- [`gog sheets table delete`](commands/gog-sheets-table-delete.md)
|
||||
|
||||
@ -18,6 +18,8 @@ import (
|
||||
|
||||
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 {
|
||||
@ -201,7 +203,7 @@ func (c *SheetsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
|
||||
valueInputOption := strings.TrimSpace(c.ValueInput)
|
||||
if valueInputOption == "" {
|
||||
valueInputOption = "USER_ENTERED"
|
||||
valueInputOption = sheetsDefaultValueInputOption
|
||||
}
|
||||
|
||||
if err := dryRunExit(ctx, flags, "sheets.update", map[string]any{
|
||||
@ -309,7 +311,7 @@ func (c *SheetsAppendCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
|
||||
valueInputOption := strings.TrimSpace(c.ValueInput)
|
||||
if valueInputOption == "" {
|
||||
valueInputOption = "USER_ENTERED"
|
||||
valueInputOption = sheetsDefaultValueInputOption
|
||||
}
|
||||
insertDataOption := strings.TrimSpace(c.Insert)
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ type SheetsTableCmd struct {
|
||||
List SheetsTableListCmd `cmd:"" default:"withargs" help:"List tables in a spreadsheet"`
|
||||
Get SheetsTableGetCmd `cmd:"" name:"get" aliases:"show,info" help:"Get a table"`
|
||||
Create SheetsTableCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a table"`
|
||||
Append SheetsTableAppendCmd `cmd:"" name:"append" aliases:"add-row,add-rows" help:"Append rows to a table"`
|
||||
Delete SheetsTableDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove,del" help:"Delete a table"`
|
||||
}
|
||||
|
||||
|
||||
155
internal/cmd/sheets_table_append.go
Normal file
155
internal/cmd/sheets_table_append.go
Normal file
@ -0,0 +1,155 @@
|
||||
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 SheetsTableAppendCmd struct {
|
||||
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
|
||||
TableID string `arg:"" name:"tableId" help:"Table ID or table name"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (c *SheetsTableAppendCmd) Run(ctx context.Context, flags *RootFlags) error {
|
||||
u := ui.FromContext(ctx)
|
||||
spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID))
|
||||
in := strings.TrimSpace(c.TableID)
|
||||
if spreadsheetID == "" {
|
||||
return usage("empty spreadsheetId")
|
||||
}
|
||||
if in == "" {
|
||||
return usage("empty tableId")
|
||||
}
|
||||
|
||||
values, err := parseSheetsAppendValues(c.ValuesJSON, c.Values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valueInputOption := strings.TrimSpace(c.ValueInput)
|
||||
if valueInputOption == "" {
|
||||
valueInputOption = sheetsDefaultValueInputOption
|
||||
}
|
||||
|
||||
account, err := requireAccount(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc, err := newSheetsService(ctx, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tables, err := fetchSpreadsheetTables(ctx, svc, spreadsheetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table, found, err := resolveSheetsTable(in, tables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return usagef("unknown table %q", in)
|
||||
}
|
||||
if strings.TrimSpace(table.A1) == "" {
|
||||
return fmt.Errorf("table %q has no bounded A1 range", table.TableID)
|
||||
}
|
||||
if widthErr := validateSheetsTableAppendWidth(table, values); widthErr != nil {
|
||||
return widthErr
|
||||
}
|
||||
|
||||
if dryRunErr := dryRunExit(ctx, flags, "sheets.table.append", map[string]any{
|
||||
"spreadsheet_id": spreadsheetID,
|
||||
"table_id": table.TableID,
|
||||
"name": table.Name,
|
||||
"range": table.A1,
|
||||
"values": values,
|
||||
"value_input_option": valueInputOption,
|
||||
"insert_data_option": "INSERT_ROWS",
|
||||
}); dryRunErr != nil {
|
||||
return dryRunErr
|
||||
}
|
||||
|
||||
resp, err := svc.Spreadsheets.Values.Append(spreadsheetID, table.A1, &sheets.ValueRange{Values: values}).
|
||||
ValueInputOption(valueInputOption).
|
||||
InsertDataOption("INSERT_ROWS").
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil || resp.Updates == nil {
|
||||
return fmt.Errorf("append response missing update metadata")
|
||||
}
|
||||
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
|
||||
"tableId": table.TableID,
|
||||
"name": table.Name,
|
||||
"tableRange": table.A1,
|
||||
"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
|
||||
}
|
||||
|
||||
func parseSheetsAppendValues(valuesJSON string, values []string) ([][]interface{}, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(valuesJSON) != "":
|
||||
b, err := resolveInlineOrFileBytes(valuesJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read --values-json: %w", err)
|
||||
}
|
||||
var parsed [][]interface{}
|
||||
if err := json.Unmarshal(b, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON values: %w", err)
|
||||
}
|
||||
if len(parsed) == 0 {
|
||||
return nil, fmt.Errorf("provide at least one row")
|
||||
}
|
||||
return parsed, nil
|
||||
case len(values) > 0:
|
||||
rawValues := strings.Join(values, " ")
|
||||
rows := strings.Split(rawValues, ",")
|
||||
parsed := make([][]interface{}, 0, len(rows))
|
||||
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)
|
||||
}
|
||||
parsed = append(parsed, rowData)
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("provide values as args or via --values-json")
|
||||
}
|
||||
}
|
||||
|
||||
func validateSheetsTableAppendWidth(table sheetsTableItem, values [][]interface{}) error {
|
||||
if len(table.Columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
width := len(table.Columns)
|
||||
for i, row := range values {
|
||||
if len(row) > width {
|
||||
return usagef("row %d has %d cells, but table %q has %d columns", i+1, len(row), table.Name, width)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -217,6 +217,115 @@ func TestSheetsTableListGetDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsTableAppendCmd(t *testing.T) {
|
||||
origNew := newSheetsService
|
||||
t.Cleanup(func() { newSheetsService = origNew })
|
||||
|
||||
var gotRange string
|
||||
var gotInsert string
|
||||
var gotInput string
|
||||
var gotValues sheets.ValueRange
|
||||
|
||||
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"},
|
||||
"tables": []map[string]any{
|
||||
{
|
||||
"tableId": "tbl1",
|
||||
"name": "Tasks",
|
||||
"range": map[string]any{
|
||||
"sheetId": 42,
|
||||
"startRowIndex": 0,
|
||||
"endRowIndex": 4,
|
||||
"startColumnIndex": 0,
|
||||
"endColumnIndex": 3,
|
||||
},
|
||||
"columnProperties": []map[string]any{
|
||||
{"columnIndex": 0, "columnName": "Task", "columnType": "TEXT"},
|
||||
{"columnIndex": 1, "columnName": "Amount", "columnType": "DOUBLE"},
|
||||
{"columnIndex": 2, "columnName": "Done", "columnType": "BOOLEAN"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case strings.Contains(path, "/spreadsheets/s1/values/") && strings.Contains(path, ":append") && r.Method == http.MethodPost:
|
||||
gotRange = strings.TrimSuffix(strings.TrimPrefix(path, "/spreadsheets/s1/values/"), ":append")
|
||||
gotInsert = r.URL.Query().Get("insertDataOption")
|
||||
gotInput = r.URL.Query().Get("valueInputOption")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotValues); err != nil {
|
||||
t.Fatalf("decode append: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"updates": map[string]any{
|
||||
"updatedRange": "Sheet1!A4:C4",
|
||||
"updatedRows": 1,
|
||||
"updatedColumns": 3,
|
||||
"updatedCells": 3,
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
installSheetsTestService(t, srv)
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
cmd := &SheetsTableAppendCmd{}
|
||||
if err := runKong(t, cmd, []string{
|
||||
"s1",
|
||||
"Tasks",
|
||||
"--values-json", `[["Write docs",2,true]]`,
|
||||
}, newCmdJSONContext(t), &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("append table: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if gotRange != "Sheet1!A1:C4" {
|
||||
t.Fatalf("append range = %q", gotRange)
|
||||
}
|
||||
if gotInsert != "INSERT_ROWS" {
|
||||
t.Fatalf("insertDataOption = %q", gotInsert)
|
||||
}
|
||||
if gotInput != sheetsDefaultValueInputOption {
|
||||
t.Fatalf("valueInputOption = %q", gotInput)
|
||||
}
|
||||
if len(gotValues.Values) != 1 || len(gotValues.Values[0]) != 3 {
|
||||
t.Fatalf("values = %#v", gotValues.Values)
|
||||
}
|
||||
if !strings.Contains(out, `"tableId": "tbl1"`) || !strings.Contains(out, `"updatedRange": "Sheet1!A4:C4"`) {
|
||||
t.Fatalf("missing append output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetsTableAppendRejectsTooWideRows(t *testing.T) {
|
||||
table := sheetsTableItem{
|
||||
Name: "Tasks",
|
||||
Columns: []sheetsTableColumnItem{
|
||||
{ColumnIndex: 0, ColumnName: "Task"},
|
||||
{ColumnIndex: 1, ColumnName: "Done"},
|
||||
},
|
||||
}
|
||||
err := validateSheetsTableAppendWidth(table, [][]interface{}{{"a", "b", "c"}})
|
||||
if err == nil {
|
||||
t.Fatal("expected width error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "has 3 cells") {
|
||||
t.Fatalf("error = %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func installSheetsTestService(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user