Skip to content

Commit

Permalink
csv: implement --append
Browse files Browse the repository at this point in the history
The csv subcommand can now append data to the end of a table found in
range. Fixes #6.

See: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append

To append the contents of data.csv to the end of Sheet1, for example:

$ cat data.csv | gsheet --id SHEETS_DOC_ID --range Sheet1 --append
  • Loading branch information
cristoper committed Oct 20, 2023
1 parent 0a7631f commit 57f4d57
Show file tree
Hide file tree
Showing 5 changed files with 113 additions and 28 deletions.
4 changes: 4 additions & 0 deletions cmd/gsheet/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ var app = &cli.App{
Name: "range",
Usage: "Sheet range to update or get (A1 notation)",
},
&cli.BoolFlag{
Name: "append",
Usage: "If set, append to end of any data in range",
},
&cli.StringFlag{
Name: "sep",
Value: ",",
Expand Down
30 changes: 20 additions & 10 deletions cmd/gsheet/sheets.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ import (
)

func newSheetAction(c *cli.Context) error {
if c.String("id") == "" {
return fmt.Errorf("The --id flag is required")
}
if c.String("id") == "" {
return fmt.Errorf("The --id flag is required")
}
return sheetSvc.NewSheet(c.String("id"), c.String("name"))
}

func deleteSheetAction(c *cli.Context) error {
if c.String("id") == "" {
return fmt.Errorf("The --id flag is required")
}
if c.String("id") == "" {
return fmt.Errorf("The --id flag is required")
}
return sheetSvc.DeleteSheet(c.String("id"), c.String("name"))
}

Expand Down Expand Up @@ -56,11 +56,21 @@ func rangeSheetAction(c *cli.Context) error {
} else {
// otherwise stdin is connected to a pipe or file
// send data
resp, err := sheetSvc.UpdateRangeCSV(c.String("id"), c.String("range"), os.Stdin)
if err != nil {
return err
if c.Bool("append") {
// append
resp, err := sheetSvc.AppendRangeCSV(c.String("id"), c.String("range"), os.Stdin)
if err != nil {
return err
}
fmt.Printf("Updated %d cells\n", resp.Updates.UpdatedCells)
} else {
// overwrite
resp, err := sheetSvc.UpdateRangeCSV(c.String("id"), c.String("range"), os.Stdin)
if err != nil {
return err
}
fmt.Printf("Updated %d cells\n", resp.UpdatedCells)
}
fmt.Printf("Updated %d cells\n", resp.UpdatedCells)
}
return nil
}
20 changes: 20 additions & 0 deletions gsheets/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestSheetIntegration(t *testing.T) {
// - (Create new spreadsheet with drive package)
// - Create new sheet
// - Update data in sheet via csv
// - Append data to sheet
// - Clear sheet
// - Delete sheet
// - (Use drive package to delete document)
Expand Down Expand Up @@ -103,6 +104,25 @@ func TestSheetIntegration(t *testing.T) {
t.Fail()
}

// test append
appendResp, err := svcSheet.AppendRangeCSV(testfile.Id, "TEST", strings.NewReader(testData))
if err != nil {
t.Fatal(err)
}
if appendResp.Updates.UpdatedCells != 12 {
t.Fatal("Unexpected number of cells updated")
}
vals, err = svcSheet.GetRangeCSV(testfile.Id, "TEST")
if err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(string(vals), "\n", "") !=
strings.ReplaceAll(testData + testData, "\n", "") {
t.Log("Updated data does not match test data")
t.Log(vals, []byte(testData))
t.Fail()
}

err = svcSheet.Clear(testfile.Id, "TEST")
if err != nil {
t.Fatal(err)
Expand Down
72 changes: 59 additions & 13 deletions gsheets/sheets.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type ssService interface {

// Define an interface so we can mock the SpreadsheetsValuesService if we need to
type valueService interface {
Append(string, string, *sheets.ValueRange) *sheets.SpreadsheetsValuesAppendCall
BatchGet(string) *sheets.SpreadsheetsValuesBatchGetCall
BatchUpdate(string, *sheets.BatchUpdateValuesRequest) *sheets.SpreadsheetsValuesBatchUpdateCall
BatchClear(string, *sheets.BatchClearValuesRequest) *sheets.SpreadsheetsValuesBatchClearCall
Expand Down Expand Up @@ -60,9 +61,9 @@ func (svc *Service) SpreadsheetsService() *sheets.SpreadsheetsService {

// NewSheet creates a new sheet on spreadsheet identified by 'id'
func (svc *Service) NewSheet(id, title string) error {
if id == "" {
return errors.New("id cannot be empty")
}
if id == "" {
return errors.New("id cannot be empty")
}
_, err := svc.sheet.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{
&sheets.Request{
Expand Down Expand Up @@ -99,9 +100,9 @@ func (svc *Service) SheetFromTitle(id, title string) (*int64, error) {
// DeleteSheet deletes the sheet with 'title' from spreadsheet doc identified
// by 'id'
func (svc *Service) DeleteSheet(id, title string) error {
if id == "" {
return errors.New("id cannot be empty")
}
if id == "" {
return errors.New("id cannot be empty")
}
// find sheet matching title
sheetId, err := svc.SheetFromTitle(id, title)
if err != nil {
Expand Down Expand Up @@ -213,12 +214,8 @@ func (svc *Service) UpdateRangeRaw(id, a1Range string, values [][]interface{}) (
return resp.Responses[0], nil
}

// UpdateRangeStrings update values in 'a1Range' in the spreadsheet doc
// identified by 'id' to 'values'.
// Values will be parsed by Google Sheets as if they were typed in by the user
// (so strings containing numerals may be converted to numbers, etc.)
func (svc *Service) UpdateRangeStrings(id, a1Range string, values [][]string) (*sheets.UpdateValuesResponse, error) {

// cast [][]string to [][]interface{} for passing csv data to Google Sheets
func strToInterface(values [][]string) [][]interface{} {
// make a [][]interface{} to hold typecast values
var vals = make([][]interface{}, len(values))
for i := range vals {
Expand All @@ -230,10 +227,59 @@ func (svc *Service) UpdateRangeStrings(id, a1Range string, values [][]string) (*
vals[r][c] = v
}
}
return vals
}

// AppendRangeStrings appends 'values' to any table found in 'a1Range' in the
// spreadsheet doc identified by 'id'
// Values will be parsed by Google Sheets as if they were typed in by the user
// (so strings containing numerals may be converted to numbers, etc.)
// see: https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append
func (svc *Service) AppendRangeStrings(id, a1Range string, values [][]string) (*sheets.AppendValuesResponse, error) {

// cast strings to interfaces
vals := strToInterface(values)

resp, err := svc.values.Append(id, a1Range, &sheets.ValueRange{
Values: vals,
}).
ValueInputOption("USER_ENTERED").
Context(svc.ctx).
Do()
if err != nil {
return nil, err
}
return resp, nil
}

// AppendRangeCSV appends 'values' to any table found in 'a1Range' in the
// spreadsheet doc identified by 'id'
// 'values' is an io.Reader which supplies text in csv format.
// Values will be parsed by Google Sheets as if they were typed in by the user
// (so strings containing numerals may be converted to numbers, etc.)
func (svc *Service) AppendRangeCSV(id, a1Range string, values io.Reader) (*sheets.AppendValuesResponse, error) {
csvR := csv.NewReader(values)
csvR.FieldsPerRecord = -1 // disable field checks
csvR.Comma = svc.Sep
rows, err := csvR.ReadAll()
if err != nil {
return nil, err
}
return svc.AppendRangeStrings(id, a1Range, rows)
}

// UpdateRangeStrings update values in 'a1Range' in the spreadsheet doc
// identified by 'id' to 'values'.
// Values will be parsed by Google Sheets as if they were typed in by the user
// (so strings containing numerals may be converted to numbers, etc.)
func (svc *Service) UpdateRangeStrings(id, a1Range string, values [][]string) (*sheets.UpdateValuesResponse, error) {

// cast strings to interfaces
vals := strToInterface(values)

resp, err := svc.values.BatchUpdate(id, &sheets.BatchUpdateValuesRequest{
Data: []*sheets.ValueRange{
&sheets.ValueRange{
{
MajorDimension: "ROWS",
Range: a1Range,
Values: vals,
Expand Down
15 changes: 10 additions & 5 deletions readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ With `gsheet` you can:
- Pipe csv data from stdin to a Google Sheet range +
`cat data.csv | gsheet csv --id 1o88FhvAXg8Q_ZMFudQLuZ1ShsigbAgJ --range 'Sheet1'`
- Pipe csv data from a Google Sheet range to stdout +
`gsheet csv --id 1o88FhvAXg8Q_ZMFudQLuZ1ShsigbAgJ --range "'Sheet!'A1:D20" > data.csv`
`gsheet csv --id 1o88FhvAXg8Q_ZMFudQLuZ1ShsigbAgJ --range 'Sheet!A1:D20' > data.csv`
- Clear a Google Sheet range +
`gsheet clear --id 1o88FhvAXg8Q_ZMFudQLuZ1ShsigbAgJ --range Sheet2`
- Create and delete sheets of a Spreadsheet document +
Expand Down Expand Up @@ -98,7 +98,9 @@ Remember that for any of the commands to work you must have the GOOGLE_APPLICATI
=== Sheet commands
==== csv and clear

The `csv` command is the heart of `gsheet`. If you pipe csv data to it on std input, it sends the data to the specified range of the Sheets document identified by the `--id` flag. If you don't connect stdin to a pipe, then it will read the specified range and output it to stdout in csv format.
The `csv` command is the heart of `gsheet`. If you pipe csv data to it on std input, it sends the data to the specified range of the Sheets document identified by the `--id` flag. If you pass the `--append` flag, data will be appended to the last row of data found in range.

If you don't connect stdin to a pipe, then it will read the specified range and output it to stdout in csv format.

NOTE: `csv` does not clear the range before updating data in a Sheets document. If the piped data is smaller (fewer rows or columns) than the specified range, then any pre-existing data in the spreadsheet will remain after the update. Use `gsheet clear` to clear a range.

Expand All @@ -108,6 +110,9 @@ NOTE: `csv` does not clear the range before updating data in a Sheets document.
gsheet --id SHEETS_DOC_ID clear --range Sheet1
cat data.csv | gsheet --id SHEETS_DOC_ID --range Sheet1
# Append the contents of data.csv after the lat line of existing data in Sheet1
cat data.csv | gsheet --id SHEETS_DOC_ID --range Sheet1 --append
# Read a specific range of a sheet to output.csv
# (You can always single quote sheet names and include the exclamation point in
# the single quotes so that the shell doesn't try to interpret it.)
Expand All @@ -120,7 +125,7 @@ An existing sheet can be sorted by any (single) column in either descending (def

[source,sh]
----
# Sort sheet by B coloumn in ascending order
# Sort sheet by B column in ascending order
sort --id SHEET_NAME -name Sheet1 --column=1 --asc
----

Expand Down Expand Up @@ -213,8 +218,8 @@ All of the Sheets related functions are in the `gsheets` package (`gsheets/sheet

Online godoc documentation for the packages can be found here:

- https://pkg.go.dev/github.com/cristoper/gsheet@v0.1.0/gdrive
- https://pkg.go.dev/github.com/cristoper/gsheet@v0.1.0/gsheets
- https://pkg.go.dev/github.com/cristoper/gsheet/gdrive
- https://pkg.go.dev/github.com/cristoper/gsheet/gsheets

For a quick-and-dirty example of how to use the packages look at the `integration_test.go` file included in each package.

Expand Down

0 comments on commit 57f4d57

Please sign in to comment.