├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── authentication.go ├── chart.go ├── client.go ├── error.go ├── go.mod ├── go.sum ├── spreadsheet.go ├── values.go └── values_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | go_version: [1.23.x] 18 | steps: 19 | 20 | - name: Set up Go 1.x 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go_version }} 24 | 25 | - name: Check out code into the Go module directory 26 | uses: actions/checkout@v4 27 | 28 | - name: Test 29 | run: go test -v ./... 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kataras2006@hotmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all read our [Code of Conduct](CODE_OF_CONDUCT.md). 4 | 5 | ## Found a bug? 6 | 7 | Open a new [issue](https://github.com/kataras/sheets/issues/new). 8 | * Write the Operating System and the version of your machine. 9 | * Describe your problem, what did you expect to see and what you see instead. 10 | * If it's a feature request, describe your idea as better as you can. 11 | 12 | ## Code 13 | 14 | 1. Fork the [repository](https://github.com/kataras/sheets). 15 | 2. Make your changes. 16 | 3. Compare & Push the PR from [here](https://github.com/kataras/sheets/compare). 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-2024 Gerasimos Maropoulos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sheets 2 | 3 | [![build status](https://img.shields.io/github/actions/workflow/status/kataras/sheets/ci.yml?style=for-the-badge)](https://github.com/kataras/sheets/actions) [![report card](https://img.shields.io/badge/report%20card-a%2B-ff3333.svg?style=for-the-badge)](https://goreportcard.com/report/github.com/kataras/sheets) [![godocs](https://img.shields.io/badge/go-%20docs-488AC7.svg?style=for-the-badge)](https://pkg.go.dev/github.com/kataras/sheets) 4 | 5 | Lightweight [Google Spreadsheets](https://docs.google.com/spreadsheets) Client written in Go. 6 | 7 | This package is under active development and a **work-in-progress** project. You should NOT use it on production. Please consider using the official [Google's Sheets client for Go](https://developers.google.com/sheets/api/quickstart/go) instead. 8 | 9 | ## Installation 10 | 11 | The only requirement is the [Go Programming Language](https://go.dev/dl). 12 | 13 | ```sh 14 | $ go get github.com/kataras/sheets@latest 15 | ``` 16 | 17 | ## Getting Started 18 | 19 | First of all, navigate to and enable the Sheets API Service in your [Google Console](https://console.cloud.google.com/). Place the secret client service account or token file as `client_secret.json` near the executable example. 20 | 21 | Example Code: 22 | 23 | ```go 24 | package main 25 | 26 | import ( 27 | "context" 28 | "time" 29 | 30 | "github.com/kataras/sheets" 31 | ) 32 | 33 | func main() { 34 | ctx := context.TODO() 35 | // or .Token(ctx, ...) 36 | client := sheets.NewClient(sheets.ServiceAccount(ctx, "client_secret.json")) 37 | 38 | var ( 39 | spreadsheetID := "1Ku0YXrcy8Nqmji7ABS8AmLAyxP5duQIRwmaAJAqyMYY" 40 | dataRange := "NamedRange or selectors like A1:E4 or *" 41 | records []struct{ 42 | Timestamp time.Time 43 | Email string 44 | Username string 45 | IgnoredMe string `sheets:"-"` 46 | }{} 47 | ) 48 | 49 | // Fill the "records" slice from a spreadsheet of one or more data range. 50 | err := client.ReadSpreadsheet(ctx, &records, spreadsheetID, dataRange) 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | // Update a spreadsheet on specific range. 56 | updated, err := client.UpdateSpreadsheet(ctx, spreadsheetID, sheets.ValueRange{ 57 | Range: "A2:Z", 58 | MajorDimension: sheets.Rows, 59 | Values: [][]interface{}{ 60 | {"updated record value: 1.1", "updated record value: 1.2"}, 61 | {"updated record value: 2.1", "updated record value: 2.2"}, 62 | }, 63 | }) 64 | 65 | // Clears record values of a spreadsheet. 66 | cleared, err := client.ClearSpreadsheet(ctx, spreadsheetID, "A1:E5") 67 | 68 | // [...] 69 | } 70 | ``` 71 | 72 | ## License 73 | 74 | This software is licensed under the [MIT License](LICENSE). 75 | -------------------------------------------------------------------------------- /authentication.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | 12 | "golang.org/x/oauth2" 13 | "golang.org/x/oauth2/google" 14 | ) 15 | 16 | const ( 17 | // ScopeReadOnly is the readonly oauth2 scope. 18 | ScopeReadOnly = "https://www.googleapis.com/auth/spreadsheets.readonly" 19 | // ScopeReadWrite is the full-access oauth2 scope. 20 | ScopeReadWrite = "https://www.googleapis.com/auth/spreadsheets" 21 | ) 22 | 23 | // ServiceAccount is an oauth2 authentication function which 24 | // can be passed on the `New` package-level function. 25 | // 26 | // It requires Sheet -> Share button to the email of the service account 27 | // but it does not need to keep and maintain a token. 28 | // 29 | // It panics on errors. 30 | func ServiceAccount(ctx context.Context, serviceAccountFile string, scopes ...string) http.RoundTripper { 31 | b, err := os.ReadFile(serviceAccountFile) 32 | if err != nil { 33 | log.Fatalf("Unable to read service account secret file: %v", err) 34 | } 35 | 36 | if len(scopes) == 0 { 37 | scopes = []string{ScopeReadOnly} 38 | } 39 | 40 | config, err := google.JWTConfigFromJSON(b, scopes...) 41 | if err != nil { 42 | log.Fatalf("Unable to parse service account secret file to config: %v", err) 43 | } 44 | client := config.Client(ctx) 45 | return client.Transport 46 | } 47 | 48 | // Token is an oauth2 authentication function which 49 | // can be passed on the `New` package-level function. 50 | // It accepts a token file and optionally scopes (see `ScopeReadOnly` and `ScopeReadWrite` package-level variables). 51 | // At the future it may accept scopes from different APIs (e.g google drive to save the spreadsheets on a specified folder). 52 | // 53 | // It panics on errors. 54 | func Token(ctx context.Context, credentialsFile, tokenFile string, scopes ...string) http.RoundTripper { 55 | b, err := ioutil.ReadFile(credentialsFile) 56 | if err != nil { 57 | log.Fatalf("Unable to read client secret file: %v", err) 58 | } 59 | 60 | if len(scopes) == 0 { 61 | scopes = []string{ScopeReadOnly} 62 | } 63 | 64 | // If modifying these scopes, delete your previously saved token.json. 65 | config, err := google.ConfigFromJSON(b, scopes...) 66 | if err != nil { 67 | log.Fatalf("Unable to parse client secret file to config: %v", err) 68 | } 69 | 70 | client := getClient(ctx, tokenFile, config) 71 | return client.Transport 72 | } 73 | 74 | // Retrieve a token, saves the token, then returns the generated client. 75 | func getClient(ctx context.Context, tokenFile string, config *oauth2.Config) *http.Client { 76 | // The file token.json stores the user's access and refresh tokens, and is 77 | // created automatically when the authorization flow completes for the first 78 | // time. 79 | tok, err := tokenFromFile(tokenFile) 80 | if err != nil { 81 | tok = getTokenFromWeb(config) 82 | saveToken(tokenFile, tok) 83 | } 84 | return config.Client(ctx, tok) 85 | } 86 | 87 | // Request a token from the web, then returns the retrieved token. 88 | func getTokenFromWeb(config *oauth2.Config) *oauth2.Token { 89 | authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) 90 | fmt.Printf("Go to the following link in your browser then type the "+ 91 | "authorization code: \n%v\n", authURL) 92 | 93 | var authCode string 94 | if _, err := fmt.Scan(&authCode); err != nil { 95 | log.Fatalf("Unable to read authorization code: %v", err) 96 | } 97 | 98 | tok, err := config.Exchange(context.TODO(), authCode) 99 | if err != nil { 100 | log.Fatalf("Unable to retrieve token from web: %v", err) 101 | } 102 | return tok 103 | } 104 | 105 | // Retrieves a token from a local file. 106 | func tokenFromFile(file string) (*oauth2.Token, error) { 107 | f, err := os.Open(file) 108 | if err != nil { 109 | return nil, err 110 | } 111 | defer f.Close() 112 | 113 | tok := &oauth2.Token{} 114 | err = json.NewDecoder(f).Decode(tok) 115 | return tok, err 116 | } 117 | 118 | // Saves a token to a file path. 119 | func saveToken(path string, token *oauth2.Token) { 120 | fmt.Printf("Saving credential file to: %s\n", path) 121 | f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 122 | if err != nil { 123 | log.Fatalf("Unable to cache oauth token: %v", err) 124 | } 125 | defer f.Close() 126 | 127 | _ = json.NewEncoder(f).Encode(token) 128 | } 129 | -------------------------------------------------------------------------------- /chart.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | type ( 4 | // Chart a chart embedded in a sheet. 5 | Chart struct { 6 | // ChartID is The ID of the chart. 7 | ChartID int64 `json:"chartId,omitempty"` 8 | // Position is the position of the chart. 9 | Position ChartPosition `json:"position,omitempty"` 10 | // Spec is the specification of the chart. 11 | Spec ChartSpec `json:"spec,omitempty"` 12 | } 13 | 14 | // ChartPosition is the position of an embedded object such as a chart. 15 | ChartPosition struct { 16 | // NewSheet: If true, the embedded object is put on a new sheet whose ID 17 | // is automatically chosen. Used only when writing. 18 | NewSheet bool `json:"newSheet,omitempty"` 19 | } 20 | 21 | // ChartSpec is the specifications of a chart. 22 | ChartSpec struct { 23 | // BasicChart is A basic chart specification, can be one of many kinds of charts. 24 | // See BasicChartType for the list of all 25 | // charts this supports. 26 | BasicChart BasicChart `json:"basicChart,omitempty"` 27 | // Title is the title of the chart. 28 | Title string `json:"title,omitempty"` 29 | // Subtitle is the subtitle of the chart. 30 | Subtitle string `json:"subtitle,omitempty"` 31 | } 32 | 33 | // BasicChart is the specification for a basic chart. 34 | BasicChart struct { 35 | // ChartType is the type of the chart. 36 | // 37 | // Possible values: 38 | // "BAR" 39 | // "LINE" 40 | // "AREA" 41 | // "COLUMN" 42 | // "SCATTER" 43 | // "COMBO" 44 | // "STEPPED_AREA" 45 | ChartType string `json:"chartType,omitempty"` 46 | 47 | // HeaderCount is the number of rows or columns in the data that are 48 | // "headers". 49 | // If not set, Google Sheets will guess how many rows are headers 50 | // based 51 | // on the data. 52 | // 53 | // (Note that ChartAxis.Title may override the axis title 54 | // inferred from the header values.) 55 | HeaderCount int64 `json:"headerCount,omitempty"` 56 | 57 | // LegendPosition is the position of the chart legend. 58 | // use. 59 | // 60 | // Possible values: 61 | // "BOTTOM_LEGEND" - The legend is rendered on the bottom of the 62 | // chart. 63 | // "LEFT_LEGEND" - The legend is rendered on the left of the chart. 64 | // "RIGHT_LEGEND" - The legend is rendered on the right of the chart. 65 | // "TOP_LEGEND" - The legend is rendered on the top of the chart. 66 | // "NO_LEGEND" - No legend is rendered. 67 | LegendPosition string `json:"legendPosition,omitempty"` 68 | 69 | // StackedType is the stacked type for charts that support vertical 70 | // stacking. 71 | // Applies to Area, Bar, Column, Combo, and Stepped Area charts. 72 | // 73 | // Possible values: 74 | // "NOT_STACKED" - Series are not stacked. 75 | // "STACKED" - Series values are stacked, each value is rendered 76 | // vertically beginning 77 | // from the top of the value below it. 78 | // "PERCENT_STACKED" - Vertical stacks are stretched to reach the top 79 | // of the chart, with 80 | // values laid out as percentages of each other. 81 | StackedType string `json:"stackedType,omitempty"` 82 | 83 | // ThreeDimensional if true to make the chart 3D. 84 | // Applies to Bar and Column charts. 85 | ThreeDimensional bool `json:"threeDimensional,omitempty"` 86 | 87 | // LineSmoothing sets whether all lines should be rendered smooth or 88 | // straight by default. Applies to Line charts. 89 | LineSmoothing bool `json:"lineSmoothing,omitempty"` 90 | 91 | // Axis is the axis on the chart. 92 | Axis []ChartAxis `json:"axis,omitempty"` 93 | // Domains is the domain of data this is charting. 94 | // Only a single domain is supported. 95 | Domains []ChartDomain `json:"domains,omitempty"` 96 | 97 | // Series is the data this chart is visualizing. 98 | Series []ChartSeries `json:"series,omitempty"` 99 | } 100 | 101 | // ChartAxis an axis of the chart. 102 | // A chart may not have more than one axis per axis position. 103 | ChartAxis struct { 104 | // Position is the position of this axis. 105 | // 106 | // Possible values: 107 | // "BOTTOM_AXIS" - The axis rendered at the bottom of a chart. 108 | // For most charts, this is the standard major axis. 109 | // For bar charts, this is a minor axis. 110 | // "LEFT_AXIS" - The axis rendered at the left of a chart. 111 | // For most charts, this is a minor axis. 112 | // For bar charts, this is the standard major axis. 113 | // "RIGHT_AXIS" - The axis rendered at the right of a chart. 114 | // For most charts, this is a minor axis. 115 | // For bar charts, this is an unusual major axis. 116 | Position string `json:"position,omitempty"` 117 | // Title is the title of this axis. If set, this overrides any title 118 | // inferred from headers of the data. 119 | Title string `json:"title,omitempty"` 120 | } 121 | 122 | // ChartDomain is the domain of a chart. 123 | // For example, if charting stock prices over time, this would be the date. 124 | ChartDomain struct { 125 | // Domain is the data of the domain. For example, if charting stock prices 126 | // over time, 127 | // this is the data representing the dates. 128 | Domain ChartData `json:"domain,omitempty"` 129 | 130 | // Series is the data this chart is visualizing. 131 | Series []ChartSeries `json:"series,omitempty"` 132 | 133 | // Reversed if true to reverse the order of the domain values (horizontal 134 | // axis). 135 | Reversed bool `json:"reversed,omitempty"` 136 | } 137 | 138 | // ChartData is the data included in a domain or series. 139 | ChartData struct { 140 | // SourceRange is the source ranges of the data. 141 | SourceRange ChartSourceRange `json:"sourceRange,omitempty"` 142 | } 143 | 144 | // ChartSourceRange is the source ranges for a chart. 145 | ChartSourceRange struct { 146 | // Sources is the ranges of data for a series or domain. 147 | // Exactly one dimension must have a length of 1, 148 | // and all sources in the list must have the same dimension 149 | // with length 1. 150 | // The domain (if it exists) & all series must have the same number 151 | // of source ranges. If using more than one source range, then the 152 | // source 153 | // range at a given offset must be in order and contiguous across the 154 | // domain 155 | // and series. 156 | // 157 | // For example, these are valid configurations: 158 | // 159 | // domain sources: A1:A5 160 | // series1 sources: B1:B5 161 | // series2 sources: D6:D10 162 | // 163 | // domain sources: A1:A5, C10:C12 164 | // series1 sources: B1:B5, D10:D12 165 | // series2 sources: C1:C5, E10:E12 166 | Sources []GridRange `json:"sources,omitempty"` 167 | } 168 | 169 | // GridRange is a range on a sheet. 170 | // All indexes are zero-based. 171 | // Indexes are half open, e.g the start index is inclusive 172 | // and the end index is exclusive -- [start_index, end_index). 173 | // Missing indexes indicate the range is unbounded on that side. 174 | // 175 | // For example, if "Sheet1" is sheet ID 0, then: 176 | // 177 | // `Sheet1!A1:A1 == sheet_id: 0, 178 | // start_row_index: 0, end_row_index: 1, 179 | // start_column_index: 0, end_column_index: 1` 180 | // 181 | // `Sheet1!A3:B4 == sheet_id: 0, 182 | // start_row_index: 2, end_row_index: 4, 183 | // start_column_index: 0, end_column_index: 2` 184 | // 185 | // `Sheet1!A:B == sheet_id: 0, 186 | // start_column_index: 0, end_column_index: 2` 187 | // 188 | // `Sheet1!A5:B == sheet_id: 0, 189 | // start_row_index: 4, 190 | // start_column_index: 0, end_column_index: 2` 191 | // 192 | // `Sheet1 == sheet_id:0` 193 | // 194 | // The start index must always be less than or equal to the end 195 | // index. 196 | // If the start index equals the end index, then the range is 197 | // empty. 198 | // Empty ranges are typically not meaningful and are usually rendered in 199 | // the 200 | // UI as `#REF!`. 201 | GridRange struct { 202 | // EndColumnIndex is the end column (exclusive) of the range, or not set 203 | // if unbounded. 204 | EndColumnIndex int64 `json:"endColumnIndex,omitempty"` 205 | 206 | // EndRowIndex is the end row (exclusive) of the range, or not set if 207 | // unbounded. 208 | EndRowIndex int64 `json:"endRowIndex,omitempty"` 209 | 210 | // SheetID is the sheet this range is on. 211 | SheetID int64 `json:"sheetId,omitempty"` 212 | 213 | // StartColumnIndex is the start column (inclusive) of the range, or not 214 | // set if unbounded. 215 | StartColumnIndex int64 `json:"startColumnIndex,omitempty"` 216 | 217 | // StartRowIndex is the start row (inclusive) of the range, or not set if 218 | // unbounded. 219 | StartRowIndex int64 `json:"startRowIndex,omitempty"` 220 | } 221 | 222 | // ChartSeries is a single series of data in a chart. 223 | // For example, if charting stock prices over time, multiple series may 224 | // exist, 225 | // one for the "Open Price", "High Price", "Low Price" and "Close 226 | // Price". 227 | ChartSeries struct { 228 | // Series is the data being visualized in this chart series. 229 | Series ChartData `json:"series,omitempty"` 230 | 231 | // TargetAxis is the minor axis that will specify the range of values for 232 | // this series. 233 | // For example, if charting stocks over time, the "Volume" series 234 | // may want to be pinned to the right with the prices pinned to the 235 | // left, 236 | // because the scale of trading volume is different than the scale 237 | // of 238 | // prices. 239 | // It is an error to specify an axis that isn't a valid minor axis 240 | // for the chart's type. 241 | // 242 | // Possible values: 243 | // "BOTTOM_AXIS" - The axis rendered at the bottom of a chart. 244 | // For most charts, this is the standard major axis. 245 | // For bar charts, this is a minor axis. 246 | // "LEFT_AXIS" - The axis rendered at the left of a chart. 247 | // For most charts, this is a minor axis. 248 | // For bar charts, this is the standard major axis. 249 | // "RIGHT_AXIS" - The axis rendered at the right of a chart. 250 | // For most charts, this is a minor axis. 251 | // For bar charts, this is an unusual major axis. 252 | TargetAxis string `json:"targetAxis,omitempty"` 253 | 254 | // Type is the type of this series. Valid only if the 255 | // chartType is 256 | // COMBO. 257 | // Different types will change the way the series is visualized. 258 | // Only LINE, AREA, 259 | // and COLUMN are supported. 260 | // 261 | // Possible values: 262 | // "BAR" 263 | // "LINE" 264 | // "AREA" 265 | // "COLUMN" 266 | // "SCATTER" 267 | // "COMBO" 268 | // "STEPPED_AREA" 269 | Type string `json:"type,omitempty"` 270 | } 271 | ) 272 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/url" 12 | ) 13 | 14 | // Client holds the google spreadsheet custom API Client. 15 | type Client struct { 16 | HTTPClient *http.Client 17 | } 18 | 19 | // NewClient creates and returns a new spreadsheet HTTP Client. 20 | // It accepts `http.RoundTriper` which is used for oauth2 authentication, 21 | // see `ServiceAccount` and `Token` package-level functions. 22 | func NewClient(authentication http.RoundTripper) *Client { 23 | return &Client{ 24 | HTTPClient: &http.Client{ 25 | Transport: authentication, 26 | }, 27 | } 28 | } 29 | 30 | // A RequestOption can be passed on `Do` method to modify a Request. 31 | type RequestOption interface{ Apply(*http.Request) } 32 | 33 | // Query is a `RequestOption` which sets URL query values to the Request. 34 | type Query url.Values 35 | 36 | // Apply implements the `RequestOption` interface. 37 | // It adds the "q" values to the request. 38 | func (q Query) Apply(r *http.Request) { 39 | query := r.URL.Query() 40 | for k, v := range q { 41 | query[k] = v 42 | } 43 | r.URL.RawQuery = query.Encode() 44 | } 45 | 46 | type gzipReadCloser struct { 47 | gzipReader io.ReadCloser 48 | responseReader io.ReadCloser 49 | } 50 | 51 | func (r *gzipReadCloser) Close() (lastErr error) { 52 | r.gzipReader.Close() 53 | return r.responseReader.Close() 54 | } 55 | 56 | func (r *gzipReadCloser) Read(p []byte) (n int, err error) { 57 | return r.gzipReader.Read(p) 58 | } 59 | 60 | // Do sends an HTTP request and returns an HTTP response. 61 | // It respects gzip and some settings specified to google's spreadsheet API. 62 | // The last option can be used to modify a request before sent to the server. 63 | func (c *Client) Do(ctx context.Context, method, url string, body io.Reader, options ...RequestOption) (*http.Response, error) { 64 | req, err := http.NewRequest(method, url, body) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | query := req.URL.Query() 70 | query.Set("prettyPrint", "false") 71 | req.URL.RawQuery = query.Encode() 72 | 73 | req.Header.Add("Accept", "application/json") 74 | req.Header.Add("Accept-Encoding", "gzip") 75 | 76 | for _, opt := range options { 77 | opt.Apply(req) 78 | } 79 | 80 | response, err := c.HTTPClient.Do(req.WithContext(ctx)) 81 | if err != nil { 82 | select { 83 | case <-ctx.Done(): 84 | err = ctx.Err() 85 | default: 86 | } 87 | if response != nil && response.Body != nil { 88 | response.Body.Close() 89 | } 90 | 91 | return nil, err 92 | } 93 | 94 | if encoding := response.Header.Get("Content-Encoding"); encoding == "gzip" { 95 | r, err := gzip.NewReader(response.Body) 96 | if err != nil { 97 | return nil, err 98 | } 99 | response.Body = &gzipReadCloser{responseReader: response.Body, gzipReader: r} 100 | } 101 | 102 | return response, err 103 | } 104 | 105 | // ReadJSON fires a request to "url" and binds a JSON response to the "toPtr". 106 | func (c *Client) ReadJSON(ctx context.Context, method, url string, requestData, toPtr interface{}, options ...RequestOption) error { 107 | var requestBody io.Reader 108 | 109 | if requestData != nil { 110 | buf := new(bytes.Buffer) 111 | err := json.NewEncoder(buf).Encode(requestData) 112 | if err != nil { 113 | return err 114 | } 115 | 116 | requestBody = buf 117 | } 118 | 119 | resp, err := c.Do(ctx, method, url, requestBody, options...) 120 | if err != nil { 121 | return err 122 | } 123 | defer resp.Body.Close() 124 | 125 | if resp.StatusCode != http.StatusOK { 126 | return newResourceError(resp) 127 | } 128 | 129 | return json.NewDecoder(resp.Body).Decode(toPtr) 130 | } 131 | 132 | const spreadsheetURL = "https://sheets.googleapis.com/v4/spreadsheets/%s" 133 | 134 | // GetSpreadsheetInfo returns general information about a spreadsheet based on the provided "spreadsheetID". 135 | func (c *Client) GetSpreadsheetInfo(ctx context.Context, spreadsheetID string) (*Spreadsheet, error) { 136 | url := fmt.Sprintf(spreadsheetURL, spreadsheetID) 137 | sd := &Spreadsheet{} 138 | err := c.ReadJSON(ctx, http.MethodGet, url, nil, sd) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | return sd, nil 144 | } 145 | 146 | const ( 147 | spreadsheetValuesURL = spreadsheetURL + "/values/%s" 148 | spreadsheetValuesBatchGetURL = spreadsheetURL + "/values:batchGet" 149 | spreadsheetValuesClearURL = spreadsheetValuesURL + ":clear" 150 | spreadsheetBatchUpdateURL = spreadsheetURL + ":batchUpdate" 151 | ) 152 | 153 | // Range returns record values of a spreadsheet based on the provided "dataRanges", if more than one data range then it sends a batch request. 154 | // See `ReadSpreadsheet` method too. 155 | func (c *Client) Range(ctx context.Context, spreadsheetID string, dataRanges ...string) ([]ValueRange, error) { 156 | if len(dataRanges) == 1 { 157 | // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get 158 | url := fmt.Sprintf(spreadsheetValuesURL, spreadsheetID, dataRanges[0]) 159 | 160 | var payload ValueRange 161 | err := c.ReadJSON(ctx, http.MethodGet, url, nil, &payload) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | return []ValueRange{payload}, nil 167 | } 168 | 169 | // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet 170 | url := fmt.Sprintf(spreadsheetValuesBatchGetURL, spreadsheetID) 171 | q := Query{"ranges": dataRanges} 172 | 173 | var payload = struct { 174 | ValueRanges []ValueRange `json:"valueRanges"` 175 | }{} 176 | err := c.ReadJSON(ctx, http.MethodGet, url, nil, &payload, q) 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | return payload.ValueRanges, nil 182 | } 183 | 184 | // ReadSpreadsheet binds record values of a spreadsheet to the "dest". 185 | // See `Range` method too. 186 | func (c *Client) ReadSpreadsheet(ctx context.Context, dest interface{}, spreadsheetID string, dataRanges ...string) error { 187 | valueRanges, err := c.Range(ctx, spreadsheetID, dataRanges...) 188 | if err != nil { 189 | return err 190 | } 191 | 192 | return DecodeValueRange(dest, valueRanges...) 193 | } 194 | 195 | // ClearSpreadsheet clears values from a spreadsheet. The caller must specify the spreadsheet ID and range. 196 | // Only values are cleared -- all other properties of the cell (such as formatting, data validation, etc..) are kept. 197 | func (c *Client) ClearSpreadsheet(ctx context.Context, spreadsheetID, dataRange string) (response ClearValuesResponse, err error) { 198 | if dataRange == "" || dataRange == "*" { 199 | dataRange = "A1:Z" 200 | } 201 | 202 | // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/clear 203 | url := fmt.Sprintf(spreadsheetValuesClearURL, spreadsheetID, dataRange) 204 | err = c.ReadJSON(ctx, http.MethodPost, url, nil, &response) 205 | return 206 | } 207 | 208 | // UpdateSpreadsheet updates a spreadsheet of a range of provided "dataRange", 209 | // if "dataRange" is empty or "*" then it will update all columns specified by "values". 210 | func (c *Client) UpdateSpreadsheet(ctx context.Context, spreadsheetID string, values ValueRange) (response UpdateValuesResponse, err error) { 211 | if values.Range == "" || values.Range == "*" { 212 | values.Range = "A1:Z" 213 | } 214 | 215 | if values.MajorDimension == "" { 216 | values.MajorDimension = Rows 217 | } 218 | 219 | // https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update 220 | url := fmt.Sprintf(spreadsheetValuesURL, spreadsheetID, values.Range) 221 | 222 | q := Query{ 223 | "valueInputOption": []string{"RAW"}, 224 | "includeValuesInResponse": []string{"false"}, 225 | } 226 | 227 | err = c.ReadJSON(ctx, http.MethodPut, url, values, &response, q) 228 | 229 | return 230 | } 231 | 232 | type batchUpdate struct { 233 | Requests []batchUpdateRequest `json:"requests,omitempty"` 234 | } 235 | 236 | type batchUpdateRequest struct { 237 | AddChart batchUpdateAddChartRequest `json:"addChart,omitempty"` 238 | } 239 | 240 | type batchUpdateAddChartRequest struct { 241 | Chart Chart `json:"chart,omitempty"` 242 | } 243 | 244 | // AddChart creates or updates an existing chart to a spreadsheet. 245 | func (c *Client) AddChart(ctx context.Context, spreadsheetID string, chart Chart) (response BatchUpdateResponse, err error) { 246 | // https://developers.google.com/sheets/api/samples/charts#add_a_column_chart 247 | url := fmt.Sprintf(spreadsheetBatchUpdateURL, spreadsheetID) 248 | 249 | err = c.ReadJSON(ctx, http.MethodPost, url, batchUpdate{ 250 | Requests: []batchUpdateRequest{ 251 | {AddChart: batchUpdateAddChartRequest{ 252 | Chart: chart, 253 | }}, 254 | }, 255 | }, &response) 256 | return 257 | } 258 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | ) 8 | 9 | // ResourceError is a Client type error. 10 | // It returns from Client's method when server replies with an error. 11 | // It holds the HTTP Method, URL, Status Code and the actual error message came from server. 12 | // 13 | // See `IsResourceError` and `IsStatusError` too. 14 | type ResourceError struct { 15 | Method string 16 | URL string 17 | StatusCode int 18 | Message string 19 | } 20 | 21 | func newResourceError(resp *http.Response) *ResourceError { 22 | cause := "unspecified" 23 | 24 | if resp.Body != nil { 25 | b, err := ioutil.ReadAll(resp.Body) 26 | if err == nil { 27 | cause = string(b) 28 | } 29 | } 30 | 31 | endpoint := resp.Request.URL.String() 32 | return &ResourceError{ 33 | Method: resp.Request.Method, 34 | URL: endpoint, 35 | StatusCode: resp.StatusCode, 36 | Message: cause, 37 | } 38 | } 39 | 40 | // Error implements a Go error and returns a human-readable error text. 41 | func (e *ResourceError) Error() string { 42 | return fmt.Sprintf("resource error [%s: %s]: %d: %s", e.Method, e.URL, e.StatusCode, e.Message) 43 | } 44 | 45 | // IsStatusError reports whether a "target" error is type of `ResourceError` and the status code is the provided "statusCode" one. 46 | // Usage: 47 | // resErr, ok := IsStatusError(http.StatusNotFound, err) 48 | // 49 | // if ok { 50 | // [ressErr.Method, URL, StatusCode, Message...] 51 | // } 52 | // 53 | // See `IsResourceError` too. 54 | func IsStatusError(statusCode int, target error) (*ResourceError, bool) { 55 | if target == nil { 56 | return nil, false 57 | } 58 | 59 | t, ok := target.(*ResourceError) 60 | if !ok { 61 | return nil, false 62 | } 63 | 64 | return t, t.StatusCode == statusCode 65 | } 66 | 67 | // IsResourceError reports whether "target" is "e" ResourceError. 68 | // Returns true when all fields of "e" are equal to "target" fields 69 | // or when a "target" matching field is empty. 70 | func IsResourceError(e *ResourceError, target error) bool { 71 | if target == nil { 72 | return e == nil 73 | } 74 | 75 | t, ok := target.(*ResourceError) 76 | if !ok { 77 | return false 78 | } 79 | 80 | return (e.Method == t.Method || t.Method == "") && 81 | (e.URL == t.URL || t.URL == "") && 82 | (e.StatusCode == t.StatusCode || t.StatusCode <= 0) && 83 | (e.Message == t.Message || t.Message == "") 84 | } 85 | 86 | // Is implements the standard`errors.Is` internal interface. 87 | // It's equivalent of the `IsResourceError` package-level function. 88 | func (e *ResourceError) Is(target error) bool { // implements Go 1.13 errors.Is internal interface. 89 | return IsResourceError(e, target) 90 | } 91 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kataras/sheets 2 | 3 | go 1.23 4 | 5 | require golang.org/x/oauth2 v0.23.0 6 | 7 | require cloud.google.com/go/compute/metadata v0.3.0 // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= 2 | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 6 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 7 | -------------------------------------------------------------------------------- /spreadsheet.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | // SheetType represents the type of a Sheet. 4 | type SheetType string 5 | 6 | const ( 7 | // Grid is a Sheet type. 8 | Grid SheetType = "GRID" 9 | ) 10 | 11 | type ( 12 | // Spreadsheet holds a spreadsheet's fields. 13 | Spreadsheet struct { 14 | ID string `json:"spreadsheetId"` 15 | Properties SpreadsheetProperties `json:"properties"` 16 | Sheets []Sheet `json:"sheets"` 17 | NamedRanges []NamedRange `json:"namedRanges"` 18 | URL string `json:"spreadsheetUrl"` 19 | } 20 | 21 | // SpreadsheetProperties holds the properties of a spreadsheet. 22 | SpreadsheetProperties struct { 23 | Title string `json:"title"` 24 | Locale string `json:"locale"` 25 | Timezone string `json:"timeZone"` 26 | } 27 | 28 | // Sheet holds the sheet fields. 29 | Sheet struct { 30 | Properties SheetProperties `json:"properties"` 31 | } 32 | 33 | // SheetProperties holds the properties of a sheet. 34 | SheetProperties struct { 35 | ID string `json:"sheetId"` 36 | Title string `json:"title"` 37 | Index int `json:"index"` 38 | SheetType SheetType `json:"sheetType"` 39 | Grid SheetGrid `json:"gridProperties,omitempty"` 40 | } 41 | 42 | // SheetGrid represents the `Grid` field of `SheetProperties`. 43 | SheetGrid struct { 44 | RowCount int `json:"rowCount"` 45 | ColumnCount int `json:"columnCount"` 46 | FrozenRowCount int `json:"frozenRowCount"` 47 | } 48 | 49 | // NamedRange represents the namedRange of a request. 50 | NamedRange struct { 51 | ID string `json:"namedRangeId"` 52 | Name string `json:"name"` 53 | Range Range `json:"range"` 54 | } 55 | 56 | // Range holds the range request and response values. 57 | Range struct { 58 | SheetID string `json:"sheetId"` 59 | StartRowIndex int `json:"startRowIndex"` 60 | EndRowIndex int `json:"endRowIndex"` 61 | StartColumnIndex int `json:"startColumnIndex"` 62 | EndColumnIndex int `json:"endColumnIndex"` 63 | } 64 | 65 | // BatchUpdateResponse is the response when a batch update request is fired on a spreadsheet. 66 | BatchUpdateResponse struct { 67 | // SpreadsheetID is the spreadsheet the updates were applied to. 68 | SpreadsheetID string `json:"spreadsheetId,omitempty"` 69 | 70 | // UpdatedSpreadsheet: The spreadsheet after updates were applied. 71 | // UpdatedSpreadsheet *Spreadsheet `json:"updatedSpreadsheet,omitempty"` 72 | } 73 | ) 74 | 75 | // RangeAll returns a data range text which can be used to fetch all rows of a sheet. 76 | func (s *Sheet) RangeAll() string { 77 | // To return all values we use the sheet's title as the range, so we return that one here. 78 | return "'" + s.Properties.Title + "'" 79 | } 80 | 81 | // GetSheet finds and returns a sheet based on its "title" inside the "sd" Spreadsheet value. 82 | func (sd *Spreadsheet) GetSheet(title string) (Sheet, bool) { 83 | for _, s := range sd.Sheets { 84 | if s.Properties.Title == title || s.Properties.ID == title { 85 | return s, true 86 | } 87 | } 88 | 89 | return Sheet{}, false 90 | } 91 | -------------------------------------------------------------------------------- /values.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "sync" 7 | ) 8 | 9 | const structTag = "sheets" 10 | 11 | // Rows is the default "ROWS" ValueRange.MajorDimension value. 12 | const Rows = "ROWS" 13 | 14 | type ( 15 | // ValueRange holds data within a range of the spreadsheet. 16 | ValueRange struct { 17 | // Range are the values to cover, in A1 notation. For output, this range indicates the entire requested range, even though the values will exclude trailing rows and columns. When appending values, 18 | // this field represents the range to search for a table, 19 | // after which values will be appended. 20 | Range string `json:"range"` 21 | // The major dimension of the values. 22 | MajorDimension string `json:"majorDimension"` 23 | // Values holds the data that was read or to be written. 24 | // This is a slice of slices, the outer array representing all the data and each inner array representing a major dimension. 25 | // Each item in the inner array corresponds with one cell. 26 | // 27 | // For output, empty trailing rows and columns will not be included. 28 | // 29 | // For input, supported value types are: bool, string, and double. Null values will be skipped. 30 | // To set a cell to an empty value, set the string value to an empty string. 31 | Values [][]interface{} `json:"values"` 32 | } 33 | 34 | // ClearValuesResponse is the response when clearing values of a spreadsheet. 35 | ClearValuesResponse struct { 36 | SpreadsheetID string `json:"spreadsheetId"` 37 | ClearedRange string `json:"clearedRange"` 38 | } 39 | 40 | // UpdateValuesResponse is the response when updating a range of values in a spreadsheet. 41 | UpdateValuesResponse struct { 42 | // The spreadsheet the updates were applied to. 43 | SpreadsheetID string `json:"spreadsheetId"` 44 | // The range (in A1 notation) that updates were applied to. 45 | UpdatedRange string `json:"updatedRange"` 46 | // The number of rows where at least one cell in the row was updated. 47 | UpdatedRows int `json:"updatedRows"` 48 | // The number of columns where at least one cell in the column was updated. 49 | UpdatedColumns int `json:"updatedColumns"` 50 | // The number of cells updated. 51 | UpdatedCells int `json:"updatedCells"` 52 | // The values of the cells after updates were applied. 53 | // This is only included if the request's includeValuesInResponse field was true. 54 | UpdatedData []ValueRange `json:"updatedData"` 55 | } 56 | ) 57 | 58 | // Header is the row's header of a struct field. 59 | type Header struct { 60 | Name string // the sheet header name value. 61 | FieldIndex int 62 | FieldName string // the field name, may be identical to Name. 63 | FieldType reflect.Type 64 | } 65 | 66 | var ( 67 | // ErrOK can be returned from a custom `FieldDecoder` when 68 | // it should use the default implementation to decode a specific struct's field. 69 | // 70 | // See `DecodeValueRange` package-level function and `ReadSpreadsheet` Client's method. 71 | ErrOK = fmt.Errorf("ok") 72 | ) 73 | 74 | var ( 75 | cache = make(map[reflect.Type]*metadata) 76 | cacheMu sync.RWMutex 77 | ) 78 | 79 | type metadata struct { 80 | headers []*Header 81 | typ reflect.Type 82 | 83 | decodeFieldFunc *reflect.Value 84 | } 85 | 86 | // FieldDecoder is an inteface which a struct can implement to select custom decode implementation 87 | // instead of the default one, if `ErrOK` is returned then it will fill the field with the default implementation. 88 | type FieldDecoder interface { 89 | DecodeField(h *Header, value interface{}) error 90 | } 91 | 92 | var fieldDecoderTyp = reflect.TypeOf((*FieldDecoder)(nil)).Elem() 93 | 94 | func getMetadata(typ reflect.Type) *metadata { 95 | cacheMu.RLock() 96 | meta, ok := cache[typ] 97 | cacheMu.RUnlock() 98 | if ok { 99 | return meta 100 | } 101 | 102 | if typ.Kind() != reflect.Struct { 103 | panic("not a struct type") 104 | } 105 | 106 | n := typ.NumField() 107 | headers := make([]*Header, 0, n) 108 | 109 | for i := 0; i < n; i++ { 110 | f := typ.Field(i) 111 | if f.PkgPath != "" { // not exported. 112 | continue 113 | } 114 | 115 | name := f.Tag.Get(structTag) 116 | if name == "" { 117 | name = f.Name 118 | } else if name == "-" { 119 | continue // skip. 120 | } 121 | 122 | headers = append(headers, &Header{ 123 | Name: name, 124 | FieldIndex: i, 125 | FieldName: f.Name, 126 | FieldType: f.Type, 127 | }) 128 | } 129 | 130 | meta = &metadata{ 131 | typ: typ, 132 | headers: headers, 133 | } 134 | 135 | if typPtr := reflect.New(typ).Type(); typPtr.Implements(fieldDecoderTyp) { 136 | method, ok := typPtr.MethodByName("DecodeField") 137 | if ok { 138 | meta.decodeFieldFunc = &method.Func 139 | } 140 | } 141 | 142 | cacheMu.Lock() 143 | cache[typ] = meta 144 | cacheMu.Unlock() 145 | 146 | return meta 147 | } 148 | 149 | // DecodeValueRange binds "rangeValues" to the "dest" pointer of a struct instance. 150 | func DecodeValueRange(dest interface{}, rangeValues ...ValueRange) error { 151 | if len(rangeValues) == 0 { 152 | return nil 153 | } else if len(rangeValues[0].Values) == 0 { 154 | return nil 155 | } 156 | 157 | v := reflect.ValueOf(dest) 158 | if v.Kind() != reflect.Ptr { 159 | return fmt.Errorf("not a pointer") 160 | } 161 | 162 | elem := v.Elem() 163 | typ := elem.Type() 164 | 165 | if kind := typ.Kind(); kind == reflect.Slice { 166 | ptrElements := false 167 | // originalSliceItemTyp := typ.Elem() 168 | typ = typ.Elem() 169 | 170 | if k := typ.Kind(); k == reflect.Ptr { 171 | ptrElements = true 172 | if elemElemType := typ.Elem(); elemElemType.Kind() == reflect.Struct { 173 | typ = elemElemType 174 | } else { 175 | return fmt.Errorf("not a pointer to a slice of pointers of structs") 176 | } 177 | } else if k != reflect.Struct { 178 | return fmt.Errorf("not a pointer to a slice of structs") 179 | } 180 | 181 | meta := getMetadata(typ) 182 | for _, rangeValue := range rangeValues { 183 | // n := len(rangeValue.Values) 184 | // arr := reflect.New(reflect.MakeSlice(elem.Type(), 0, n).Type()).Elem() 185 | 186 | // if n := len(rangeValue.Values); elem.Cap() < n { 187 | // elem.SetCap(n) 188 | // } 189 | 190 | for _, row := range rangeValue.Values { 191 | newStructValue := reflect.New(typ) 192 | if err := decodeValue(row, meta, newStructValue); err != nil { 193 | return err 194 | } 195 | if !ptrElements { 196 | newStructValue = newStructValue.Elem() 197 | } 198 | 199 | elem.Set(reflect.Append(elem, newStructValue)) 200 | } 201 | } 202 | 203 | return nil 204 | } else if kind != reflect.Struct { 205 | return fmt.Errorf("not a pointer to a struct") 206 | } 207 | 208 | return decodeValue(rangeValues[0].Values[0], getMetadata(typ), v) 209 | } 210 | 211 | func decodeValue(row []interface{}, meta *metadata, newStructOrPtr reflect.Value) error { 212 | if len(row) == 0 || meta == nil || len(meta.headers) == 0 /* all fields are unexported or ignored */ { 213 | return nil 214 | } 215 | 216 | for i, value := range row { 217 | h := meta.headers[i] 218 | 219 | val := reflect.ValueOf(value) 220 | 221 | if meta.decodeFieldFunc != nil { 222 | out := meta.decodeFieldFunc.Call([]reflect.Value{newStructOrPtr, reflect.ValueOf(h), val}) 223 | if errV := out[0]; !errV.IsNil() { 224 | // if ErrOK should continue with the default behavior for this field. 225 | if err := errV.Interface().(error); err != ErrOK { 226 | return err 227 | } 228 | } else { 229 | continue 230 | } 231 | } 232 | 233 | newStructValue := newStructOrPtr.Elem() 234 | 235 | if val.Type().AssignableTo(h.FieldType) { 236 | fieldValue := newStructValue.Field(h.FieldIndex) 237 | fieldValue.Set(val) 238 | } 239 | } 240 | 241 | return nil 242 | } 243 | -------------------------------------------------------------------------------- /values_test.go: -------------------------------------------------------------------------------- 1 | package sheets 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type testRow struct { 8 | Name string 9 | Other string `sheets:"-"` 10 | Age int 11 | } 12 | 13 | func BenchmarkDecodeValueRange(b *testing.B) { 14 | lenValues := 420 15 | values := make([][]interface{}, lenValues) 16 | 17 | for i := 0; i < lenValues; i++ { 18 | values[i] = []interface{}{"makis", 27} 19 | } 20 | 21 | b.ReportAllocs() 22 | b.ResetTimer() 23 | for i := 0; i < b.N; i++ { 24 | var result []testRow 25 | 26 | if err := DecodeValueRange(&result, ValueRange{Values: values}); err != nil { 27 | b.Fatal(err) 28 | } 29 | } 30 | } 31 | 32 | func TestDecodeValueRange(t *testing.T) { 33 | var singleResult testRow 34 | 35 | DecodeValueRange(&singleResult, ValueRange{ 36 | Values: [][]interface{}{ 37 | { 38 | "makis", 39 | 27, 40 | }, 41 | }, 42 | }) 43 | 44 | // t.Logf("%#+v", singleResult) 45 | 46 | // empty value ranges (should not panic). 47 | DecodeValueRange(&singleResult) 48 | // empty value range (should not panic). 49 | DecodeValueRange(&singleResult, []ValueRange{{Values: [][]interface{}{}}}...) 50 | } 51 | 52 | type testRowFieldDecoder struct { 53 | Name string 54 | } 55 | 56 | func (t *testRowFieldDecoder) DecodeField(h *Header, value interface{}) error { 57 | t.Name = value.(string) + " custom value" 58 | return nil 59 | } 60 | 61 | func TestFieldDecoder(t *testing.T) { 62 | var names = []string{"makis", "giwrgos", "efi"} 63 | expectedNames := make([]string, len(names)) 64 | 65 | values := make([][]interface{}, len(names)) 66 | for i, name := range names { 67 | expectedNames[i] = name + " custom value" 68 | values[i] = []interface{}{name} 69 | } 70 | 71 | // test with ptr 72 | var ptrRows []*testRowFieldDecoder 73 | err := DecodeValueRange(&ptrRows, ValueRange{ 74 | Values: values, 75 | }) 76 | 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | for i, row := range ptrRows { 82 | if expected, got := expectedNames[i], row.Name; expected != got { 83 | t.Fatalf("[%d] expected %s but got %s", i, expected, got) 84 | } 85 | } 86 | 87 | // test without ptr. 88 | var rows []testRowFieldDecoder 89 | err = DecodeValueRange(&rows, ValueRange{ 90 | Values: values, 91 | }) 92 | 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | for i, row := range rows { 98 | if expected, got := expectedNames[i], row.Name; expected != got { 99 | t.Fatalf("[%d] expected %s but got %s", i, expected, got) 100 | } 101 | } 102 | } 103 | --------------------------------------------------------------------------------